You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2808 lines
120 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HikeMap Admin</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f23;
color: #fff;
min-height: 100vh;
}
.admin-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.admin-sidebar {
width: 250px;
background: #16213e;
padding: 20px 0;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
border-right: 1px solid rgba(255,255,255,0.1);
}
.admin-logo {
padding: 20px;
font-size: 1.5rem;
font-weight: bold;
color: #4CAF50;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 20px;
}
.nav-item {
display: flex;
align-items: center;
padding: 15px 20px;
color: #aaa;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
}
.nav-item:hover {
background: rgba(255,255,255,0.05);
color: #fff;
}
.nav-item.active {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
border-left: 3px solid #4CAF50;
}
.nav-item .icon {
margin-right: 12px;
font-size: 1.2rem;
}
.nav-spacer {
flex: 1;
}
/* Main Content */
.admin-main {
flex: 1;
margin-left: 250px;
padding: 30px;
}
.section {
display: none;
}
.section.active {
display: block;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.section-header h2 {
font-size: 1.8rem;
color: #fff;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
transition: all 0.2s;
}
.btn-primary {
background: #4CAF50;
color: #fff;
}
.btn-primary:hover {
background: #45a049;
}
.btn-secondary {
background: rgba(255,255,255,0.1);
color: #fff;
}
.btn-secondary:hover {
background: rgba(255,255,255,0.2);
}
.btn-danger {
background: #f44336;
color: #fff;
}
.btn-danger:hover {
background: #d32f2f;
}
.btn-warning {
background: #ff9800;
color: #fff;
}
.btn-warning:hover {
background: #f57c00;
}
.btn-small {
padding: 6px 12px;
font-size: 0.85rem;
}
/* Tables */
.data-table {
width: 100%;
border-collapse: collapse;
background: rgba(255,255,255,0.03);
border-radius: 8px;
overflow: hidden;
}
.data-table th,
.data-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.data-table th {
background: rgba(255,255,255,0.05);
font-weight: 600;
color: #aaa;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
}
.data-table tr:hover {
background: rgba(255,255,255,0.02);
}
.data-table .actions {
display: flex;
gap: 8px;
}
/* Toggle Switch */
.toggle {
position: relative;
width: 50px;
height: 26px;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.2);
border-radius: 26px;
transition: 0.3s;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: 0.3s;
}
.toggle input:checked + .toggle-slider {
background: #4CAF50;
}
.toggle input:checked + .toggle-slider:before {
transform: translateX(24px);
}
/* Badges */
.badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-admin {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
}
.badge-user {
background: rgba(100, 181, 246, 0.2);
color: #64b5f6;
}
.badge-level {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #1a1a2e;
border-radius: 12px;
padding: 30px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.modal-header h3 {
font-size: 1.4rem;
}
.modal-close {
background: none;
border: none;
color: #aaa;
font-size: 1.5rem;
cursor: pointer;
}
.modal-close:hover {
color: #fff;
}
/* Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #aaa;
font-size: 0.9rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(255,255,255,0.05);
color: #fff;
font-size: 1rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #4CAF50;
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.form-row-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.form-row-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 15px;
}
.modal-wide {
max-width: 700px;
}
.status-effect-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
}
.status-effect-section h4 {
margin-bottom: 5px;
color: #aaa;
font-size: 0.9rem;
}
/* Skill Type Badges */
.skill-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.skill-type-damage {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.skill-type-heal {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
.skill-type-buff {
background: rgba(33, 150, 243, 0.2);
color: #2196F3;
}
.skill-type-debuff {
background: rgba(156, 39, 176, 0.2);
color: #9C27B0;
}
.skill-type-status {
background: rgba(255, 152, 0, 0.2);
color: #FF9800;
}
.skill-type-utility {
background: rgba(0, 188, 212, 0.2);
color: #00BCD4;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 30px;
}
/* Settings Section */
.settings-card {
background: rgba(255,255,255,0.03);
border-radius: 12px;
padding: 25px;
margin-bottom: 20px;
}
.settings-card h3 {
margin-bottom: 20px;
color: #4CAF50;
}
/* Loading State */
.loading {
text-align: center;
padding: 40px;
color: #aaa;
}
/* Error/Success Messages */
.toast {
position: fixed;
bottom: 30px;
right: 30px;
padding: 15px 25px;
border-radius: 8px;
color: #fff;
font-weight: 500;
z-index: 2000;
animation: slideIn 0.3s ease;
}
.toast.success {
background: #4CAF50;
}
.toast.error {
background: #f44336;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Dialogue Editor */
.dialogue-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
}
.dialogue-section h4 {
margin-bottom: 15px;
color: #aaa;
font-size: 0.9rem;
}
.dialogue-phase {
margin-bottom: 20px;
}
.dialogue-phase label {
font-weight: 600;
color: #4CAF50;
}
.dialogue-items {
margin-top: 10px;
}
.dialogue-item {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.dialogue-item input {
flex: 1;
}
.dialogue-item .btn {
padding: 8px 12px;
}
/* Skills Editor */
.skills-section {
margin-top: 20px;
padding: 15px;
background: rgba(255,255,255,0.02);
border-radius: 8px;
}
.skills-section h4 {
margin-bottom: 15px;
color: #aaa;
font-size: 0.9rem;
}
.add-skill-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.add-skill-row select {
flex: 1;
}
.monster-skill-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,0.05);
border-radius: 6px;
margin-bottom: 8px;
}
.monster-skill-item .skill-name {
flex: 1;
font-weight: 500;
}
.monster-skill-item .skill-weight,
.monster-skill-item .skill-min-level {
width: 60px;
text-align: center;
}
.monster-skill-item label {
font-size: 11px;
color: #888;
}
.skill-name-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.skill-custom-name {
width: 100%;
padding: 4px 8px;
font-size: 13px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
color: #fff;
}
.skill-custom-name:focus {
outline: none;
border-color: #4CAF50;
}
.skill-custom-name::placeholder {
color: #888;
font-style: italic;
}
.skill-base-name {
font-size: 10px;
color: #666;
}
/* Stats Display */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin: 15px 0;
}
.stat-item {
background: rgba(255,255,255,0.05);
padding: 10px;
border-radius: 6px;
text-align: center;
}
.stat-item .value {
font-size: 1.2rem;
font-weight: bold;
color: #4CAF50;
}
.stat-item .label {
font-size: 0.75rem;
color: #aaa;
margin-top: 4px;
}
/* Login Screen */
.login-screen {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #0f0f23;
}
.login-box {
background: #1a1a2e;
padding: 40px;
border-radius: 12px;
text-align: center;
border: 1px solid rgba(255,255,255,0.1);
}
.login-box h2 {
margin-bottom: 20px;
color: #4CAF50;
}
.login-box p {
color: #aaa;
margin-bottom: 20px;
}
</style>
</head>
<body>
<!-- Login Screen (shown if not authenticated) -->
<div id="loginScreen" class="login-screen" style="display: none;">
<div class="login-box">
<h2>HikeMap Admin</h2>
<p>You must be logged in as an admin to access this page.</p>
<a href="/" class="btn btn-primary">Go to Login</a>
</div>
</div>
<!-- Admin Container -->
<div id="adminContainer" class="admin-container" style="display: none;">
<!-- Sidebar -->
<nav class="admin-sidebar">
<div class="admin-logo">HikeMap Admin</div>
<a class="nav-item active" data-section="monsters">
<span class="icon">&#128126;</span> Monsters
</a>
<a class="nav-item" data-section="skills">
<span class="icon">&#9889;</span> Skills
</a>
<a class="nav-item" data-section="classes">
<span class="icon">&#127942;</span> Classes
</a>
<a class="nav-item" data-section="users">
<span class="icon">&#128100;</span> Users
</a>
<a class="nav-item" data-section="settings">
<span class="icon">&#9881;</span> Settings
</a>
<div class="nav-spacer"></div>
<a class="nav-item" href="/">
<span class="icon">&#8592;</span> Back to App
</a>
</nav>
<!-- Main Content -->
<main class="admin-main">
<!-- Monsters Section -->
<section id="monsters-section" class="section active">
<div class="section-header">
<h2>Monster Types</h2>
<button class="btn btn-primary" id="addMonsterBtn">+ Add Monster</button>
</div>
<table class="data-table" id="monsterTable">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Level</th>
<th>HP</th>
<th>MP</th>
<th>ATK</th>
<th>DEF</th>
<th>XP</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="monsterTableBody">
<tr><td colspan="9" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
<!-- Skills Section -->
<section id="skills-section" class="section">
<div class="section-header">
<h2>Skills Database</h2>
<button class="btn btn-primary" id="addSkillBtn">+ Add Skill</button>
</div>
<table class="data-table" id="skillTable">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Type</th>
<th>Power</th>
<th>Accuracy</th>
<th>MP</th>
<th>Target</th>
<th>Player</th>
<th>Monster</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="skillTableBody">
<tr><td colspan="11" class="loading">Loading...</td></tr>
</tbody>
</table>
<!-- Utility Skills Subsection -->
<div style="margin-top: 40px;">
<div class="section-header" style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<h3>Utility Skills</h3>
<p style="font-size: 12px; color: #888; margin-top: 5px; margin-bottom: 15px;">
Skills that provide passive buffs outside of combat (MP/HP regen, stat boosts, XP multipliers)
</p>
</div>
<button class="btn btn-primary" id="addUtilitySkillBtn">+ Add Utility Skill</button>
</div>
<table class="data-table" id="utilitySkillTable">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Effect Type</th>
<th>Value</th>
<th>Duration</th>
<th>Cooldown</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="utilitySkillTableBody">
<tr><td colspan="8" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Classes Section -->
<section id="classes-section" class="section">
<div class="section-header">
<h2>Character Classes</h2>
<button class="btn btn-primary" id="addClassBtn">+ Add Class</button>
</div>
<table class="data-table" id="classTable">
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>Base Stats</th>
<th>Growth/Level</th>
<th>Skills</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="classTableBody">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
<!-- Users Section -->
<section id="users-section" class="section">
<div class="section-header">
<h2>Users</h2>
</div>
<table class="data-table" id="userTable">
<thead>
<tr>
<th>Username</th>
<th>Character</th>
<th>Race/Class</th>
<th>Level</th>
<th>Stats</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="userTableBody">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
<!-- Settings Section -->
<section id="settings-section" class="section">
<div class="section-header">
<h2>Game Settings</h2>
</div>
<div class="settings-card">
<h3>Monster Spawning</h3>
<div class="form-row">
<div class="form-group">
<label>Spawn Interval (seconds)</label>
<input type="number" id="setting-monsterSpawnInterval" placeholder="20" min="5" step="1">
<small style="color: #888; font-size: 11px;">How often spawn attempts occur</small>
</div>
<div class="form-group">
<label>Spawn Chance (%)</label>
<input type="number" id="setting-monsterSpawnChance" placeholder="50" min="1" max="100">
<small style="color: #888; font-size: 11px;">Percent chance per interval</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Movement Distance (meters)</label>
<input type="number" id="setting-monsterSpawnDistance" placeholder="10" min="1">
<small style="color: #888; font-size: 11px;">Distance player must move for new spawns</small>
</div>
<div class="form-group">
<label>Max Monsters Per Player</label>
<input type="number" id="setting-maxMonstersPerPlayer" placeholder="10" min="1">
<small style="color: #888; font-size: 11px;">Maximum monsters following player</small>
</div>
</div>
</div>
<div class="settings-card">
<h3>Game Balance</h3>
<div class="form-row">
<div class="form-group">
<label>XP Multiplier</label>
<input type="number" step="0.1" id="setting-xpMultiplier" placeholder="1.0">
</div>
<div class="form-group">
<label>Combat Enabled</label>
<label class="toggle">
<input type="checkbox" id="setting-combatEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>MP Regen Distance (meters)</label>
<input type="number" id="setting-mpRegenDistance" placeholder="5" min="0" step="1">
<small style="color: #888; font-size: 11px;">Meters walked to regen 1 MP (0 = disabled)</small>
</div>
<div class="form-group">
<label>MP Regen Amount</label>
<input type="number" id="setting-mpRegenAmount" placeholder="1" min="1" step="1">
<small style="color: #888; font-size: 11px;">MP gained per distance threshold</small>
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">HP Regeneration</h3>
<div class="form-row">
<div class="form-group">
<label>HP Regen Interval (seconds)</label>
<input type="number" id="setting-hpRegenInterval" placeholder="10" min="1" step="1">
<small style="color: #888; font-size: 11px;">Time between HP regen ticks</small>
</div>
<div class="form-group">
<label>HP Regen Percent</label>
<input type="number" step="0.1" id="setting-hpRegenPercent" placeholder="1" min="0.1">
<small style="color: #888; font-size: 11px;">% of max HP restored per tick</small>
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Home Base Bonuses</h3>
<div class="form-row">
<div class="form-group">
<label>Home HP Regen Multiplier</label>
<input type="number" step="0.5" id="setting-homeHpMultiplier" placeholder="3" min="1">
<small style="color: #888; font-size: 11px;">HP regen multiplier when at home (e.g., 3 = 3x faster)</small>
</div>
<div class="form-group">
<label>Home Regen Percent</label>
<input type="number" step="1" id="setting-homeRegenPercent" placeholder="5" min="1">
<small style="color: #888; font-size: 11px;">% of max HP/MP per tick when at home base</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Home Base Radius (meters)</label>
<input type="number" id="setting-homeBaseRadius" placeholder="20" min="5" step="1">
<small style="color: #888; font-size: 11px;">Distance from home to get bonuses</small>
</div>
<div class="form-group">
<!-- empty for alignment -->
</div>
</div>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Session Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Inactivity Timeout (minutes)</label>
<input type="number" id="setting-inactivityTimeout" placeholder="10" min="1" step="1">
<small style="color: #888; font-size: 11px;">Minutes of inactivity before auto-logout</small>
</div>
<div class="form-group">
<label>Logout Warning Time (seconds)</label>
<input type="number" id="setting-inactivityWarningTime" placeholder="60" min="10" step="5">
<small style="color: #888; font-size: 11px;">Warning shown this many seconds before logout</small>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button>
</div>
</section>
</main>
</div>
<!-- Monster Edit Modal -->
<div class="modal-overlay" id="monsterModal">
<div class="modal">
<div class="modal-header">
<h3 id="monsterModalTitle">Edit Monster</h3>
<button class="modal-close" onclick="closeMonsterModal()">&times;</button>
</div>
<form id="monsterForm">
<input type="hidden" id="monsterId">
<div class="form-row">
<div class="form-group">
<label>Monster Name</label>
<input type="text" id="monsterName" required placeholder="e.g., Moop">
</div>
<div class="form-group">
<label>Key (unique identifier)</label>
<input type="text" id="monsterKey" required placeholder="e.g., moop">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label>Min Level</label>
<input type="number" id="monsterMinLevel" required value="1" min="1">
</div>
<div class="form-group">
<label>Max Level</label>
<input type="number" id="monsterMaxLevel" required value="5" min="1">
</div>
<div class="form-group">
<label>Base HP</label>
<input type="number" id="monsterHp" required value="25" min="1">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label>Base ATK</label>
<input type="number" id="monsterAtk" required value="8" min="1">
</div>
<div class="form-group">
<label>Base DEF</label>
<input type="number" id="monsterDef" required value="3" min="0">
</div>
<div class="form-group">
<label>Base MP</label>
<input type="number" id="monsterMp" required value="20" min="0">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label>Base XP</label>
<input type="number" id="monsterXp" required value="10" min="1">
</div>
<div class="form-group">
<label>MP/Level</label>
<input type="number" id="monsterMpScale" required value="5" min="0">
</div>
<div class="form-group">
<!-- empty for alignment -->
</div>
</div>
<div class="form-group">
<label>Spawn Weight (higher = more common)</label>
<input type="number" id="monsterWeight" required value="100" min="1">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="monsterEnabled" checked> Enabled
</label>
</div>
<div class="skills-section">
<h4>Monster Skills</h4>
<div id="monsterSkillsList"></div>
<div class="add-skill-row">
<select id="addSkillSelect" onchange="onSkillSelectChange()">
<option value="">-- Select a skill --</option>
</select>
<input type="text" id="addSkillCustomName" placeholder="Custom name (optional)" style="width: 150px;">
<input type="number" id="addSkillWeight" placeholder="Weight" value="10" min="1" style="width: 70px;">
<input type="number" id="addSkillMinLevel" placeholder="Min Lvl" value="1" min="1" style="width: 70px;">
<button type="button" class="btn btn-secondary btn-small" onclick="addMonsterSkill()">Add</button>
</div>
<p style="font-size: 11px; color: #666; margin-top: 5px;">Custom name overrides skill name for this monster. Weight = selection probability.</p>
</div>
<div class="dialogue-section">
<h4>Dialogues (one per line)</h4>
<div class="form-group">
<label>Annoyed (0-5 min)</label>
<textarea id="dialogueAnnoyed" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Frustrated (5-10 min)</label>
<textarea id="dialogueFrustrated" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Desperate (10-30 min)</label>
<textarea id="dialogueDesperate" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Philosophical (30-60 min)</label>
<textarea id="dialoguePhilosophical" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Existential (60+ min)</label>
<textarea id="dialogueExistential" placeholder="One dialogue line per line..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeMonsterModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Monster</button>
</div>
</form>
</div>
</div>
<!-- User Edit Modal -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<div class="modal-header">
<h3>Edit User</h3>
<button class="modal-close" onclick="closeUserModal()">&times;</button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label>Username</label>
<input type="text" id="userUsername" disabled>
</div>
<div class="form-row">
<div class="form-group">
<label>Character Name</label>
<input type="text" id="userCharacterName">
</div>
<div class="form-group">
<label>Level</label>
<input type="number" id="userLevel" min="1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>XP</label>
<input type="number" id="userXp" min="0">
</div>
<div class="form-group">
<label>HP / Max HP</label>
<div class="form-row">
<input type="number" id="userHp" min="0">
<input type="number" id="userMaxHp" min="1">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>MP / Max MP</label>
<div class="form-row">
<input type="number" id="userMp" min="0">
<input type="number" id="userMaxMp" min="0">
</div>
</div>
<div class="form-group">
<label>ATK / DEF</label>
<div class="form-row">
<input type="number" id="userAtk" min="1">
<input type="number" id="userDef" min="0">
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" onclick="resetUserProgress()">Reset Progress</button>
<button type="button" class="btn btn-warning" onclick="resetUserHomeBase()">Reset Home Base</button>
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Skill Edit Modal -->
<div class="modal-overlay" id="skillModal">
<div class="modal modal-wide">
<div class="modal-header">
<h3 id="skillModalTitle">Edit Skill</h3>
<button class="modal-close" onclick="closeSkillModal()">&times;</button>
</div>
<form id="skillForm">
<input type="hidden" id="skillEditId">
<div class="form-row">
<div class="form-group">
<label>Skill Name</label>
<input type="text" id="skillName" required placeholder="e.g., Double Attack">
</div>
<div class="form-group">
<label>ID (unique identifier)</label>
<input type="text" id="skillId" required placeholder="e.g., double_attack">
</div>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="skillDescription" placeholder="e.g., Attack twice with reduced power">
</div>
<div class="form-row">
<div class="form-group">
<label>Type</label>
<select id="skillType" required onchange="handleSkillTypeChange()">
<option value="damage">Damage</option>
<option value="heal">Heal</option>
<option value="buff">Buff</option>
<option value="debuff">Debuff</option>
<option value="status">Status Effect</option>
<option value="utility">Utility (Out-of-Combat)</option>
</select>
</div>
<div class="form-group">
<label>Target</label>
<select id="skillTarget">
<option value="enemy">Enemy</option>
<option value="self">Self</option>
<option value="all_enemies">All Enemies</option>
</select>
</div>
</div>
<div class="form-row-4">
<div class="form-group">
<label>MP Cost</label>
<input type="number" id="skillMpCost" value="0" min="0">
</div>
<div class="form-group">
<label>Base Power</label>
<input type="number" id="skillBasePower" value="100" min="0">
</div>
<div class="form-group">
<label>Accuracy (%)</label>
<input type="number" id="skillAccuracy" value="95" min="0" max="100">
</div>
<div class="form-group">
<label>Hit Count</label>
<input type="number" id="skillHitCount" value="1" min="1">
</div>
</div>
<div class="status-effect-section">
<h4>Status Effect (optional)</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">For skills that apply effects over time (poison, burn, etc.)</p>
<div class="form-row-3">
<div class="form-group">
<label>Effect Type</label>
<select id="skillStatusType">
<option value="">None</option>
<option value="poison">Poison</option>
<option value="burn">Burn</option>
<option value="bleed">Bleed</option>
<option value="regen">Regeneration</option>
<option value="stun">Stun</option>
</select>
</div>
<div class="form-group">
<label>Damage/Turn</label>
<input type="number" id="skillStatusDamage" value="5" min="0">
</div>
<div class="form-group">
<label>Duration (turns)</label>
<input type="number" id="skillStatusDuration" value="3" min="1">
</div>
</div>
</div>
<!-- Utility Skill Configuration (hidden by default) -->
<div class="status-effect-section" id="utilityConfigSection" style="display: none;">
<h4>Utility Skill Configuration</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
Configure the buff effect for this utility skill (e.g., Second Wind, XP Boost)
</p>
<div class="form-row">
<div class="form-group">
<label>Effect Type</label>
<select id="utilityEffectType">
<option value="hp_regen_multiplier">HP Regen Multiplier</option>
<option value="mp_regen_multiplier">MP Regen Multiplier</option>
<option value="atk_boost_flat">ATK Boost (Flat)</option>
<option value="atk_boost_percent">ATK Boost (%)</option>
<option value="def_boost_flat">DEF Boost (Flat)</option>
<option value="def_boost_percent">DEF Boost (%)</option>
<option value="xp_multiplier">XP Multiplier</option>
</select>
</div>
<div class="form-group">
<label>Effect Value</label>
<input type="number" id="utilityEffectValue" value="2.0" min="0" step="0.1"
placeholder="e.g., 2.0 for 2x multiplier">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Duration (hours)</label>
<input type="number" id="utilityDurationHours" value="1" min="0.1" step="0.1">
</div>
<div class="form-group">
<label>Cooldown (hours)</label>
<input type="number" id="utilityCooldownHours" value="24" min="0" step="0.5">
</div>
</div>
</div>
<div class="form-row-3" style="margin-top: 15px;">
<div class="form-group">
<label>
<input type="checkbox" id="skillPlayerUsable" checked> Player Can Use
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="skillMonsterUsable" checked> Monster Can Use
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="skillEnabled" checked> Enabled
</label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeSkillModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Skill</button>
</div>
</form>
</div>
</div>
<!-- Class Edit Modal -->
<div class="modal-overlay" id="classModal">
<div class="modal modal-wide">
<div class="modal-header">
<h3 id="classModalTitle">Edit Class</h3>
<button class="modal-close" onclick="closeClassModal()">&times;</button>
</div>
<form id="classForm">
<input type="hidden" id="classEditId">
<div class="form-row">
<div class="form-group">
<label>Class Name</label>
<input type="text" id="className" required placeholder="e.g., Trail Runner">
</div>
<div class="form-group">
<label>ID (unique identifier)</label>
<input type="text" id="classId" required placeholder="e.g., trail_runner">
</div>
</div>
<div class="form-group">
<label>Description</label>
<textarea id="classDescription" placeholder="Class description..."></textarea>
</div>
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Base Stats (Level 1)</h4>
<div class="form-row-3">
<div class="form-group">
<label>Base HP</label>
<input type="number" id="classBaseHp" value="100" min="1">
</div>
<div class="form-group">
<label>Base MP</label>
<input type="number" id="classBaseMp" value="50" min="0">
</div>
<div class="form-group">
<label>Base ATK</label>
<input type="number" id="classBaseAtk" value="12" min="1">
</div>
</div>
<div class="form-row-4">
<div class="form-group">
<label>Base DEF</label>
<input type="number" id="classBaseDef" value="8" min="0">
</div>
<div class="form-group">
<label>Base Accuracy</label>
<input type="number" id="classBaseAccuracy" value="90" min="1" max="100">
</div>
<div class="form-group">
<label>Base Dodge</label>
<input type="number" id="classBaseDodge" value="10" min="0" max="100">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="classEnabled"> Enabled
</label>
</div>
</div>
<h4 style="margin: 20px 0 10px; color: #4CAF50;">Stat Growth (Per Level)</h4>
<div class="form-row-4">
<div class="form-group">
<label>HP/Level</label>
<input type="number" id="classHpPerLevel" value="10" min="0">
</div>
<div class="form-group">
<label>MP/Level</label>
<input type="number" id="classMpPerLevel" value="5" min="0">
</div>
<div class="form-group">
<label>ATK/Level</label>
<input type="number" id="classAtkPerLevel" value="2" min="0">
</div>
<div class="form-group">
<label>DEF/Level</label>
<input type="number" id="classDefPerLevel" value="1" min="0">
</div>
</div>
<div class="skills-section">
<h4>Class Skills</h4>
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">
Assign skills to this class. Set unlock level and choice group for skill selection at level-up.
<br>Skills with the same choice group at the same level = player picks one.
</p>
<div id="classSkillsList"></div>
<div class="add-skill-row">
<select id="addClassSkillSelect">
<option value="">-- Select a skill --</option>
</select>
<input type="number" id="addClassSkillLevel" placeholder="Lvl" value="1" min="1" style="width: 60px;">
<input type="number" id="addClassSkillChoiceGroup" placeholder="Group" style="width: 70px;" title="Choice group (blank for auto-learn)">
<input type="text" id="addClassSkillName" placeholder="Custom name" style="width: 120px;">
<button type="button" class="btn btn-secondary btn-small" onclick="addClassSkill()">Add</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeClassModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Class</button>
</div>
</form>
</div>
</div>
<script>
// State
let accessToken = localStorage.getItem('accessToken');
let monsters = [];
let users = [];
let settings = {};
let allSkills = [];
let currentMonsterSkills = []; // Skills for the monster being edited
let allClasses = [];
let currentClassSkills = []; // Skills for the class being edited
// API Helper
async function api(endpoint, options = {}) {
const url = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
...options.headers
}
});
if (response.status === 401 || response.status === 403) {
showLoginScreen();
throw new Error('Not authorized');
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'API error');
}
return data;
}
// Toast notifications
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Show login screen
function showLoginScreen() {
document.getElementById('loginScreen').style.display = 'flex';
document.getElementById('adminContainer').style.display = 'none';
}
// Show admin container
function showAdminContainer() {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('adminContainer').style.display = 'flex';
}
// Check auth and admin status
async function checkAuth() {
if (!accessToken) {
showLoginScreen();
return;
}
try {
// Try to fetch admin data - will fail if not admin
await api('/api/admin/settings');
showAdminContainer();
loadAllData();
} catch (e) {
showLoginScreen();
}
}
// Navigation
document.querySelectorAll('.nav-item[data-section]').forEach(item => {
item.addEventListener('click', () => {
const section = item.dataset.section;
// Update nav
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
// Update sections
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.getElementById(`${section}-section`).classList.add('active');
});
});
// Load all data
async function loadAllData() {
loadMonsters();
loadUsers();
loadSettings();
loadSkillsAdmin(); // Load skills for admin (includes disabled)
loadClasses();
}
// ============= MONSTERS =============
async function loadMonsters() {
try {
const data = await api('/api/admin/monster-types');
monsters = data.monsterTypes || [];
renderMonsterTable();
} catch (e) {
showToast('Failed to load monsters: ' + e.message, 'error');
}
}
async function loadSkills() {
try {
const data = await api('/api/skills');
allSkills = data.skills || [];
populateSkillSelect();
} catch (e) {
console.error('Failed to load skills:', e);
}
}
function populateSkillSelect() {
const select = document.getElementById('addSkillSelect');
select.innerHTML = '<option value="">-- Select a skill --</option>';
// Support both snake_case (admin API) and camelCase (public API)
allSkills.filter(s => s.monster_usable || s.monsterUsable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadMonsterSkills(monsterTypeId) {
if (!monsterTypeId) {
currentMonsterSkills = [];
renderMonsterSkills();
return;
}
try {
const data = await api('/api/admin/monster-skills');
currentMonsterSkills = (data.monsterSkills || []).filter(ms => ms.monster_type_id === monsterTypeId);
renderMonsterSkills();
} catch (e) {
console.error('Failed to load monster skills:', e);
currentMonsterSkills = [];
renderMonsterSkills();
}
}
function renderMonsterSkills() {
const container = document.getElementById('monsterSkillsList');
if (currentMonsterSkills.length === 0) {
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Monster will only use basic attack.</p>';
return;
}
container.innerHTML = currentMonsterSkills.map(ms => {
const skill = allSkills.find(s => s.id === ms.skill_id);
const baseName = skill ? skill.name : ms.skill_id;
const displayName = ms.custom_name || baseName;
const hasCustomName = !!ms.custom_name;
return `
<div class="monster-skill-item" data-id="${ms.id}">
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(ms.custom_name || '')}"
placeholder="${escapeHtml(baseName)}"
onchange="updateMonsterSkillName(${ms.id}, this.value)"
title="Custom name (leave empty for default: ${escapeHtml(baseName)})">
<span class="skill-base-name">${escapeHtml(baseName)}</span>
</div>
<div>
<label>Wt</label>
<input type="number" class="skill-weight" value="${ms.weight}" min="1"
onchange="updateMonsterSkill(${ms.id}, 'weight', this.value)">
</div>
<div>
<label>Lvl</label>
<input type="number" class="skill-min-level" value="${ms.min_level}" min="1"
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeMonsterSkill(${ms.id})">✕</button>
</div>
`;
}).join('');
}
function onSkillSelectChange() {
// Clear custom name when selecting a new skill
document.getElementById('addSkillCustomName').value = '';
}
async function addMonsterSkill() {
const monsterId = document.getElementById('monsterId').value;
const skillId = document.getElementById('addSkillSelect').value;
const customName = document.getElementById('addSkillCustomName').value.trim();
const weight = parseInt(document.getElementById('addSkillWeight').value) || 10;
const minLevel = parseInt(document.getElementById('addSkillMinLevel').value) || 1;
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!monsterId) {
showToast('Please save the monster first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentMonsterSkills.some(ms => ms.skill_id === skillId)) {
showToast('This skill is already assigned to this monster', 'error');
return;
}
try {
const result = await api('/api/admin/monster-skills', {
method: 'POST',
body: JSON.stringify({
monster_type_id: monsterId,
skill_id: skillId,
custom_name: customName || null,
weight: weight,
min_level: minLevel
})
});
// Add to local array immediately (optimistic update)
const skill = allSkills.find(s => s.id === skillId);
currentMonsterSkills.push({
id: result?.id || Date.now(), // Use response ID or temp ID
monster_type_id: monsterId,
skill_id: skillId,
skill_name: skill?.name || skillId,
custom_name: customName || null,
weight: weight,
min_level: minLevel
});
showToast('Skill added');
renderMonsterSkills();
loadMonsterSkills(monsterId); // Background refresh for consistency
document.getElementById('addSkillSelect').value = '';
document.getElementById('addSkillCustomName').value = '';
} catch (e) {
showToast('Failed to add skill: ' + e.message, 'error');
}
}
async function updateMonsterSkillName(id, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ custom_name: value.trim() || null })
});
// Update local state
const ms = currentMonsterSkills.find(s => s.id === id);
if (ms) ms.custom_name = value.trim() || null;
} catch (e) {
showToast('Failed to update skill name: ' + e.message, 'error');
}
}
async function updateMonsterSkill(id, field, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ [field]: parseInt(value) })
});
// Update local state immediately
const ms = currentMonsterSkills.find(s => s.id === id);
if (ms) ms[field] = parseInt(value);
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeMonsterSkill(id) {
try {
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
currentMonsterSkills = currentMonsterSkills.filter(ms => ms.id !== id);
showToast('Skill removed');
renderMonsterSkills();
const monsterId = document.getElementById('monsterId').value;
loadMonsterSkills(monsterId); // Background refresh for consistency
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
function renderMonsterTable() {
const tbody = document.getElementById('monsterTableBody');
if (monsters.length === 0) {
tbody.innerHTML = '<tr><td colspan="10">No monsters found</td></tr>';
return;
}
tbody.innerHTML = monsters.map(m => `
<tr>
<td><strong>${escapeHtml(m.name)}</strong></td>
<td><code>${escapeHtml(m.key)}</code></td>
<td>${m.min_level}-${m.max_level}</td>
<td>${m.base_hp}</td>
<td>${m.base_mp || 20}</td>
<td>${m.base_atk}</td>
<td>${m.base_def}</td>
<td>${m.base_xp}</td>
<td>
<label class="toggle">
<input type="checkbox" ${m.enabled ? 'checked' : ''}
onchange="toggleMonster('${m.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editMonster('${m.id}')">Edit</button>
<button class="btn btn-secondary btn-small" onclick="cloneMonster('${m.id}')">Clone</button>
<button class="btn btn-danger btn-small" onclick="deleteMonster('${m.id}')">Delete</button>
</td>
</tr>
`).join('');
}
async function toggleMonster(id, enabled) {
try {
await api(`/api/admin/monster-types/${id}/enabled`, {
method: 'PATCH',
body: JSON.stringify({ enabled })
});
showToast(enabled ? 'Monster enabled' : 'Monster disabled');
loadMonsters();
} catch (e) {
showToast('Failed to toggle monster: ' + e.message, 'error');
loadMonsters();
}
}
async function deleteMonster(id) {
if (!confirm('Are you sure you want to delete this monster?')) return;
try {
await api(`/api/admin/monster-types/${id}`, { method: 'DELETE' });
// Remove from local array immediately
monsters = monsters.filter(m => m.id !== id);
showToast('Monster deleted');
renderMonsterTable();
loadMonsters(); // Background refresh for consistency
} catch (e) {
showToast('Failed to delete monster: ' + e.message, 'error');
}
}
async function editMonster(id) {
const monster = monsters.find(m => m.id === id);
if (!monster) return;
document.getElementById('monsterModalTitle').textContent = 'Edit Monster';
document.getElementById('monsterId').value = monster.id;
document.getElementById('monsterName').value = monster.name;
document.getElementById('monsterKey').value = monster.key;
document.getElementById('monsterMinLevel').value = monster.min_level;
document.getElementById('monsterMaxLevel').value = monster.max_level;
document.getElementById('monsterHp').value = monster.base_hp;
document.getElementById('monsterAtk').value = monster.base_atk;
document.getElementById('monsterDef').value = monster.base_def;
document.getElementById('monsterMp').value = monster.base_mp || 20;
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
document.getElementById('monsterXp').value = monster.base_xp;
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
document.getElementById('monsterEnabled').checked = monster.enabled;
// Parse dialogues
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Load monster skills
await loadMonsterSkills(monster.id);
document.getElementById('monsterModal').classList.add('active');
}
function cloneMonster(id) {
const monster = monsters.find(m => m.id === id);
if (!monster) return;
// Generate unique key suffix
let suffix = '_copy';
let newKey = monster.key + suffix;
let counter = 1;
while (monsters.some(m => m.key === newKey)) {
newKey = monster.key + suffix + counter;
counter++;
}
document.getElementById('monsterModalTitle').textContent = 'Clone Monster';
document.getElementById('monsterId').value = ''; // Empty = create new
document.getElementById('monsterName').value = monster.name + ' (Copy)';
document.getElementById('monsterKey').value = newKey;
document.getElementById('monsterMinLevel').value = monster.min_level;
document.getElementById('monsterMaxLevel').value = monster.max_level;
document.getElementById('monsterHp').value = monster.base_hp;
document.getElementById('monsterAtk').value = monster.base_atk;
document.getElementById('monsterDef').value = monster.base_def;
document.getElementById('monsterMp').value = monster.base_mp || 20;
document.getElementById('monsterMpScale').value = monster.level_scale_mp || 5;
document.getElementById('monsterXp').value = monster.base_xp;
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
document.getElementById('monsterEnabled').checked = false; // Disabled by default
// Parse and copy dialogues
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
// Clear skills (cloned monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
document.getElementById('monsterModal').classList.add('active');
}
document.getElementById('addMonsterBtn').addEventListener('click', () => {
document.getElementById('monsterModalTitle').textContent = 'Add Monster';
document.getElementById('monsterForm').reset();
document.getElementById('monsterId').value = '';
document.getElementById('monsterEnabled').checked = true;
// Clear skills (new monster needs to be saved first)
currentMonsterSkills = [];
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
document.getElementById('monsterModal').classList.add('active');
});
function closeMonsterModal() {
document.getElementById('monsterModal').classList.remove('active');
}
document.getElementById('monsterForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('monsterId').value;
const dialogues = {
annoyed: document.getElementById('dialogueAnnoyed').value.split('\n').filter(l => l.trim()),
frustrated: document.getElementById('dialogueFrustrated').value.split('\n').filter(l => l.trim()),
desperate: document.getElementById('dialogueDesperate').value.split('\n').filter(l => l.trim()),
philosophical: document.getElementById('dialoguePhilosophical').value.split('\n').filter(l => l.trim()),
existential: document.getElementById('dialogueExistential').value.split('\n').filter(l => l.trim())
};
const data = {
name: document.getElementById('monsterName').value,
key: document.getElementById('monsterKey').value,
min_level: parseInt(document.getElementById('monsterMinLevel').value),
max_level: parseInt(document.getElementById('monsterMaxLevel').value),
base_hp: parseInt(document.getElementById('monsterHp').value),
base_atk: parseInt(document.getElementById('monsterAtk').value),
base_def: parseInt(document.getElementById('monsterDef').value),
base_mp: parseInt(document.getElementById('monsterMp').value),
base_xp: parseInt(document.getElementById('monsterXp').value),
spawn_weight: parseInt(document.getElementById('monsterWeight').value),
levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 },
enabled: document.getElementById('monsterEnabled').checked,
dialogues: JSON.stringify(dialogues)
};
try {
if (id) {
await api(`/api/admin/monster-types/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Update local array immediately (optimistic update)
const idx = monsters.findIndex(m => m.id === id);
if (idx !== -1) {
monsters[idx] = { ...monsters[idx], ...data };
}
showToast('Monster updated');
} else {
const result = await api('/api/admin/monster-types', {
method: 'POST',
body: JSON.stringify(data)
});
// Add to local array immediately
if (result && result.id) {
monsters.push({ id: result.id, ...data });
}
showToast('Monster created');
}
renderMonsterTable();
closeMonsterModal();
loadMonsters(); // Background refresh for consistency
} catch (e) {
showToast('Failed to save monster: ' + e.message, 'error');
}
});
// ============= SKILLS DATABASE =============
async function loadSkillsAdmin() {
try {
const data = await api('/api/admin/skills');
allSkills = data.skills || [];
renderSkillTable();
renderUtilitySkillTable(); // Also render utility skills table
populateSkillSelect(); // Also update the monster skill dropdown
} catch (e) {
showToast('Failed to load skills: ' + e.message, 'error');
}
}
function renderSkillTable() {
const tbody = document.getElementById('skillTableBody');
// Filter out utility skills - they're shown in the Utility Skills section
const combatSkills = allSkills.filter(s => s.type !== 'utility');
if (combatSkills.length === 0) {
tbody.innerHTML = '<tr><td colspan="11">No combat skills found</td></tr>';
return;
}
tbody.innerHTML = combatSkills.map(s => {
const statusEffect = s.status_effect ? JSON.parse(s.status_effect) : null;
return `
<tr>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td><code>${escapeHtml(s.id)}</code></td>
<td><span class="skill-type-badge skill-type-${s.type}">${s.type}</span></td>
<td>${s.base_power}${s.hit_count > 1 ? ' ×' + s.hit_count : ''}</td>
<td>${s.accuracy}%</td>
<td>${s.mp_cost}</td>
<td>${s.target}</td>
<td>${s.player_usable ? '✓' : '✗'}</td>
<td>${s.monster_usable ? '✓' : '✗'}</td>
<td>
<label class="toggle">
<input type="checkbox" ${s.enabled ? 'checked' : ''}
onchange="toggleSkill('${s.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editSkill('${s.id}')">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteSkill('${s.id}')">Delete</button>
</td>
</tr>
`}).join('');
}
function renderUtilitySkillTable() {
const utilitySkills = allSkills.filter(s => s.type === 'utility');
const tbody = document.getElementById('utilitySkillTableBody');
if (utilitySkills.length === 0) {
tbody.innerHTML = '<tr><td colspan="8">No utility skills found. Add a skill with type "Utility" to see it here.</td></tr>';
return;
}
const effectLabels = {
'hp_regen_multiplier': 'HP Regen',
'mp_regen_multiplier': 'MP Regen',
'atk_boost_flat': 'ATK +',
'atk_boost_percent': 'ATK %',
'def_boost_flat': 'DEF +',
'def_boost_percent': 'DEF %',
'xp_multiplier': 'XP'
};
tbody.innerHTML = utilitySkills.map(s => {
let config = { effectType: '-', effectValue: '-', durationHours: '-', cooldownHours: '-' };
if (s.status_effect) {
try {
config = JSON.parse(s.status_effect);
} catch {}
}
const valueDisplay = config.effectType?.includes('percent') || config.effectType?.includes('multiplier')
? config.effectValue + 'x'
: '+' + config.effectValue;
return `
<tr>
<td><strong>${escapeHtml(s.name)}</strong></td>
<td><code>${escapeHtml(s.id)}</code></td>
<td><span class="skill-type-badge skill-type-utility">${effectLabels[config.effectType] || config.effectType || '-'}</span></td>
<td>${valueDisplay}</td>
<td>${config.durationHours || '-'}h</td>
<td>${config.cooldownHours || '-'}h</td>
<td>
<label class="toggle">
<input type="checkbox" ${s.enabled ? 'checked' : ''}
onchange="toggleSkill('${s.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editSkill('${s.id}')">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteSkill('${s.id}')">Delete</button>
</td>
</tr>
`}).join('');
}
function handleSkillTypeChange() {
const skillType = document.getElementById('skillType').value;
const utilitySection = document.getElementById('utilityConfigSection');
const statusEffectSection = document.querySelector('.status-effect-section:not(#utilityConfigSection)');
if (skillType === 'utility') {
utilitySection.style.display = 'block';
if (statusEffectSection) statusEffectSection.style.display = 'none';
// Auto-set defaults for utility skills
document.getElementById('skillPlayerUsable').checked = false;
document.getElementById('skillMonsterUsable').checked = false;
document.getElementById('skillTarget').value = 'self';
document.getElementById('skillMpCost').value = '0';
document.getElementById('skillBasePower').value = '0';
} else {
utilitySection.style.display = 'none';
if (statusEffectSection) statusEffectSection.style.display = 'block';
}
}
async function toggleSkill(id, enabled) {
try {
await api(`/api/admin/skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ enabled })
});
showToast(enabled ? 'Skill enabled' : 'Skill disabled');
loadSkillsAdmin();
} catch (e) {
showToast('Failed to toggle skill: ' + e.message, 'error');
loadSkillsAdmin();
}
}
async function deleteSkill(id) {
if (!confirm('Are you sure you want to delete this skill? This will also remove it from all monsters.')) return;
try {
await api(`/api/admin/skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
allSkills = allSkills.filter(s => s.id !== id);
showToast('Skill deleted');
renderSkillTable();
loadSkillsAdmin(); // Background refresh for consistency
} catch (e) {
showToast('Failed to delete skill: ' + e.message, 'error');
}
}
function editSkill(id) {
const skill = allSkills.find(s => s.id === id);
if (!skill) return;
document.getElementById('skillModalTitle').textContent = 'Edit Skill';
document.getElementById('skillEditId').value = skill.id;
document.getElementById('skillId').value = skill.id;
document.getElementById('skillId').disabled = true; // Can't change ID on edit
document.getElementById('skillName').value = skill.name;
document.getElementById('skillDescription').value = skill.description || '';
document.getElementById('skillType').value = skill.type;
document.getElementById('skillTarget').value = skill.target;
document.getElementById('skillMpCost').value = skill.mp_cost;
document.getElementById('skillBasePower').value = skill.base_power;
document.getElementById('skillAccuracy').value = skill.accuracy;
document.getElementById('skillHitCount').value = skill.hit_count;
document.getElementById('skillPlayerUsable').checked = skill.player_usable;
document.getElementById('skillMonsterUsable').checked = skill.monster_usable;
document.getElementById('skillEnabled').checked = skill.enabled;
// Parse status effect based on skill type
if (skill.status_effect) {
try {
const effect = JSON.parse(skill.status_effect);
if (skill.type === 'utility') {
// Utility skill config
document.getElementById('utilityEffectType').value = effect.effectType || 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = effect.effectValue || 2.0;
document.getElementById('utilityDurationHours').value = effect.durationHours || 1;
document.getElementById('utilityCooldownHours').value = effect.cooldownHours || 24;
// Reset combat status effect fields
document.getElementById('skillStatusType').value = '';
} else {
// Combat status effect
document.getElementById('skillStatusType').value = effect.type || '';
document.getElementById('skillStatusDamage').value = effect.damage || 5;
document.getElementById('skillStatusDuration').value = effect.duration || 3;
// Reset utility fields
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = 2.0;
document.getElementById('utilityDurationHours').value = 1;
document.getElementById('utilityCooldownHours').value = 24;
}
} catch {
document.getElementById('skillStatusType').value = '';
}
} else {
document.getElementById('skillStatusType').value = '';
document.getElementById('skillStatusDamage').value = 5;
document.getElementById('skillStatusDuration').value = 3;
// Reset utility fields to defaults
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = 2.0;
document.getElementById('utilityDurationHours').value = 1;
document.getElementById('utilityCooldownHours').value = 24;
}
// Toggle visibility of form sections based on skill type
handleSkillTypeChange();
document.getElementById('skillModal').classList.add('active');
}
document.getElementById('addSkillBtn').addEventListener('click', () => {
document.getElementById('skillModalTitle').textContent = 'Add Skill';
document.getElementById('skillForm').reset();
document.getElementById('skillEditId').value = '';
document.getElementById('skillId').disabled = false;
document.getElementById('skillPlayerUsable').checked = true;
document.getElementById('skillMonsterUsable').checked = true;
document.getElementById('skillEnabled').checked = true;
// Reset to default (damage) type and toggle visibility
document.getElementById('skillType').value = 'damage';
handleSkillTypeChange();
document.getElementById('skillModal').classList.add('active');
});
document.getElementById('addUtilitySkillBtn').addEventListener('click', () => {
document.getElementById('skillModalTitle').textContent = 'Add Utility Skill';
document.getElementById('skillForm').reset();
document.getElementById('skillEditId').value = '';
document.getElementById('skillId').disabled = false;
// Utility skills default settings
document.getElementById('skillPlayerUsable').checked = false;
document.getElementById('skillMonsterUsable').checked = false;
document.getElementById('skillEnabled').checked = true;
document.getElementById('skillTarget').value = 'self';
// Set type to utility and toggle visibility
document.getElementById('skillType').value = 'utility';
handleSkillTypeChange();
// Set default utility values
document.getElementById('utilityEffectType').value = 'mp_regen_multiplier';
document.getElementById('utilityEffectValue').value = '2.0';
document.getElementById('utilityDurationHours').value = '1';
document.getElementById('utilityCooldownHours').value = '24';
document.getElementById('skillModal').classList.add('active');
});
function closeSkillModal() {
document.getElementById('skillModal').classList.remove('active');
document.getElementById('skillId').disabled = false;
}
document.getElementById('skillForm').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('skillEditId').value;
const skillId = document.getElementById('skillId').value;
// Build status effect JSON based on skill type
let statusEffect = null;
const skillType = document.getElementById('skillType').value;
if (skillType === 'utility') {
// Utility skill - use utility config fields
statusEffect = JSON.stringify({
effectType: document.getElementById('utilityEffectType').value,
effectValue: parseFloat(document.getElementById('utilityEffectValue').value) || 2.0,
durationHours: parseFloat(document.getElementById('utilityDurationHours').value) || 1,
cooldownHours: parseFloat(document.getElementById('utilityCooldownHours').value) || 24
});
} else {
// Combat skill - use status effect fields
const statusType = document.getElementById('skillStatusType').value;
if (statusType) {
statusEffect = JSON.stringify({
type: statusType,
damage: parseInt(document.getElementById('skillStatusDamage').value) || 5,
duration: parseInt(document.getElementById('skillStatusDuration').value) || 3
});
}
}
const data = {
id: skillId,
name: document.getElementById('skillName').value,
description: document.getElementById('skillDescription').value,
type: document.getElementById('skillType').value,
target: document.getElementById('skillTarget').value,
mp_cost: parseInt(document.getElementById('skillMpCost').value) || 0,
base_power: parseInt(document.getElementById('skillBasePower').value) || 100,
accuracy: parseInt(document.getElementById('skillAccuracy').value) || 95,
hit_count: parseInt(document.getElementById('skillHitCount').value) || 1,
status_effect: statusEffect,
player_usable: document.getElementById('skillPlayerUsable').checked,
monster_usable: document.getElementById('skillMonsterUsable').checked,
enabled: document.getElementById('skillEnabled').checked
};
try {
if (editId) {
await api(`/api/admin/skills/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Update local array immediately (optimistic update)
const idx = allSkills.findIndex(s => s.id === editId);
if (idx !== -1) {
allSkills[idx] = { ...allSkills[idx], ...data };
}
showToast('Skill updated');
} else {
await api('/api/admin/skills', {
method: 'POST',
body: JSON.stringify(data)
});
// Add to local array immediately
allSkills.push({ ...data });
showToast('Skill created');
}
renderSkillTable();
closeSkillModal();
loadSkillsAdmin(); // Background refresh for consistency
} catch (e) {
showToast('Failed to save skill: ' + e.message, 'error');
}
});
// ============= CLASSES =============
async function loadClasses() {
try {
const data = await api('/api/admin/classes');
allClasses = data || [];
renderClassTable();
} catch (e) {
showToast('Failed to load classes: ' + e.message, 'error');
}
}
function renderClassTable() {
const tbody = document.getElementById('classTableBody');
if (allClasses.length === 0) {
tbody.innerHTML = '<tr><td colspan="7">No classes found</td></tr>';
return;
}
tbody.innerHTML = allClasses.map(c => `
<tr>
<td><strong>${escapeHtml(c.name)}</strong></td>
<td><code>${escapeHtml(c.id)}</code></td>
<td>
<small>HP:${c.base_hp} MP:${c.base_mp} ATK:${c.base_atk} DEF:${c.base_def}</small>
</td>
<td>
<small>+${c.hp_per_level}HP +${c.mp_per_level}MP +${c.atk_per_level}ATK +${c.def_per_level}DEF</small>
</td>
<td><span class="badge badge-level" id="class-skill-count-${c.id}">...</span></td>
<td>
<label class="toggle">
<input type="checkbox" ${c.enabled ? 'checked' : ''}
onchange="toggleClass('${c.id}', this.checked)">
<span class="toggle-slider"></span>
</label>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editClass('${c.id}')">Edit</button>
<button class="btn btn-danger btn-small" onclick="deleteClass('${c.id}')">Delete</button>
</td>
</tr>
`).join('');
// Load skill counts for each class
allClasses.forEach(async c => {
try {
const skills = await api(`/api/admin/class-skills/${c.id}`);
document.getElementById(`class-skill-count-${c.id}`).textContent = `${skills.length} skills`;
} catch (e) {
document.getElementById(`class-skill-count-${c.id}`).textContent = '?';
}
});
}
async function toggleClass(id, enabled) {
try {
await api(`/api/admin/classes/${id}/toggle`, { method: 'PUT' });
showToast(enabled ? 'Class enabled' : 'Class disabled');
loadClasses();
} catch (e) {
showToast('Failed to toggle class: ' + e.message, 'error');
loadClasses();
}
}
async function deleteClass(id) {
if (!confirm('Are you sure you want to delete this class? This will also remove all skill assignments.')) return;
try {
await api(`/api/admin/classes/${id}`, { method: 'DELETE' });
// Remove from local array immediately
allClasses = allClasses.filter(c => c.id !== id);
showToast('Class deleted');
renderClassTable();
loadClasses(); // Background refresh for consistency
} catch (e) {
showToast('Failed to delete class: ' + e.message, 'error');
}
}
async function editClass(id) {
const classData = allClasses.find(c => c.id === id);
if (!classData) return;
document.getElementById('classModalTitle').textContent = 'Edit Class';
document.getElementById('classEditId').value = classData.id;
document.getElementById('classId').value = classData.id;
document.getElementById('classId').disabled = true;
document.getElementById('className').value = classData.name;
document.getElementById('classDescription').value = classData.description || '';
document.getElementById('classBaseHp').value = classData.base_hp;
document.getElementById('classBaseMp').value = classData.base_mp;
document.getElementById('classBaseAtk').value = classData.base_atk;
document.getElementById('classBaseDef').value = classData.base_def;
document.getElementById('classBaseAccuracy').value = classData.base_accuracy;
document.getElementById('classBaseDodge').value = classData.base_dodge;
document.getElementById('classHpPerLevel').value = classData.hp_per_level;
document.getElementById('classMpPerLevel').value = classData.mp_per_level;
document.getElementById('classAtkPerLevel').value = classData.atk_per_level;
document.getElementById('classDefPerLevel').value = classData.def_per_level;
document.getElementById('classEnabled').checked = classData.enabled;
// Populate skill dropdown for adding
populateClassSkillSelect();
// Load class skills
await loadClassSkills(classData.id);
document.getElementById('classModal').classList.add('active');
}
document.getElementById('addClassBtn').addEventListener('click', () => {
document.getElementById('classModalTitle').textContent = 'Add Class';
document.getElementById('classForm').reset();
document.getElementById('classEditId').value = '';
document.getElementById('classId').disabled = false;
document.getElementById('classEnabled').checked = false;
currentClassSkills = [];
document.getElementById('classSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save class first, then edit to add skills.</p>';
populateClassSkillSelect();
document.getElementById('classModal').classList.add('active');
});
function closeClassModal() {
document.getElementById('classModal').classList.remove('active');
document.getElementById('classId').disabled = false;
}
document.getElementById('classForm').addEventListener('submit', async (e) => {
e.preventDefault();
const editId = document.getElementById('classEditId').value;
const classId = document.getElementById('classId').value;
const data = {
id: classId,
name: document.getElementById('className').value,
description: document.getElementById('classDescription').value,
base_hp: parseInt(document.getElementById('classBaseHp').value) || 100,
base_mp: parseInt(document.getElementById('classBaseMp').value) || 50,
base_atk: parseInt(document.getElementById('classBaseAtk').value) || 12,
base_def: parseInt(document.getElementById('classBaseDef').value) || 8,
base_accuracy: parseInt(document.getElementById('classBaseAccuracy').value) || 90,
base_dodge: parseInt(document.getElementById('classBaseDodge').value) || 10,
hp_per_level: parseInt(document.getElementById('classHpPerLevel').value) || 10,
mp_per_level: parseInt(document.getElementById('classMpPerLevel').value) || 5,
atk_per_level: parseInt(document.getElementById('classAtkPerLevel').value) || 2,
def_per_level: parseInt(document.getElementById('classDefPerLevel').value) || 1,
enabled: document.getElementById('classEnabled').checked
};
try {
if (editId) {
await api(`/api/admin/classes/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Update local array immediately (optimistic update)
const idx = allClasses.findIndex(c => c.id === editId);
if (idx !== -1) {
allClasses[idx] = { ...allClasses[idx], ...data };
}
showToast('Class updated');
} else {
await api('/api/admin/classes', {
method: 'POST',
body: JSON.stringify(data)
});
// Add to local array immediately
allClasses.push({ ...data });
showToast('Class created');
}
renderClassTable();
closeClassModal();
loadClasses(); // Background refresh for consistency
} catch (e) {
showToast('Failed to save class: ' + e.message, 'error');
}
});
// ============= CLASS SKILLS =============
function populateClassSkillSelect() {
const select = document.getElementById('addClassSkillSelect');
select.innerHTML = '<option value="">-- Select a skill --</option>';
// Filter to player-usable skills
allSkills.filter(s => s.player_usable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadClassSkills(classId) {
if (!classId) {
currentClassSkills = [];
renderClassSkills();
return;
}
try {
currentClassSkills = await api(`/api/admin/class-skills/${classId}`);
renderClassSkills();
} catch (e) {
console.error('Failed to load class skills:', e);
currentClassSkills = [];
renderClassSkills();
}
}
function renderClassSkills() {
const container = document.getElementById('classSkillsList');
if (currentClassSkills.length === 0) {
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Add skills below.</p>';
return;
}
// Group by unlock level
const byLevel = {};
currentClassSkills.forEach(cs => {
const lvl = cs.unlock_level || 1;
if (!byLevel[lvl]) byLevel[lvl] = [];
byLevel[lvl].push(cs);
});
let html = '';
Object.keys(byLevel).sort((a, b) => a - b).forEach(level => {
const skills = byLevel[level];
html += `<div style="margin-bottom: 10px; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px;">`;
html += `<div style="font-size: 11px; color: #4CAF50; margin-bottom: 5px;">Level ${level}</div>`;
skills.forEach(cs => {
const displayName = cs.custom_name || cs.base_name || cs.skill_id;
const choiceLabel = cs.choice_group ? `<span style="color: #FF9800; font-size: 10px;">Choice ${cs.choice_group}</span>` : '<span style="color: #4CAF50; font-size: 10px;">Auto</span>';
html += `
<div class="monster-skill-item" data-id="${cs.id}">
<div class="skill-name-section">
<input type="text" class="skill-custom-name" value="${escapeHtml(cs.custom_name || '')}"
placeholder="${escapeHtml(cs.base_name || cs.skill_id)}"
onchange="updateClassSkill(${cs.id}, 'custom_name', this.value)"
title="Custom name for this class">
<span class="skill-base-name">${escapeHtml(cs.base_name || cs.skill_id)} ${choiceLabel}</span>
</div>
<div>
<label>Lvl</label>
<input type="number" class="skill-min-level" value="${cs.unlock_level}" min="1"
onchange="updateClassSkill(${cs.id}, 'unlock_level', this.value)">
</div>
<div>
<label>Grp</label>
<input type="number" class="skill-weight" value="${cs.choice_group || ''}" min="1"
onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)"
placeholder="-" title="Choice group (empty = auto-learn)">
</div>
<button type="button" class="btn btn-danger btn-small" onclick="removeClassSkill(${cs.id})">✕</button>
</div>
`;
});
html += '</div>';
});
container.innerHTML = html;
}
async function addClassSkill() {
const classId = document.getElementById('classEditId').value;
const skillId = document.getElementById('addClassSkillSelect').value;
const unlockLevel = parseInt(document.getElementById('addClassSkillLevel').value) || 1;
const choiceGroupVal = document.getElementById('addClassSkillChoiceGroup').value;
const choiceGroup = choiceGroupVal ? parseInt(choiceGroupVal) : null;
const customName = document.getElementById('addClassSkillName').value.trim();
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!classId) {
showToast('Please save the class first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentClassSkills.some(cs => cs.skill_id === skillId)) {
showToast('This skill is already assigned to this class', 'error');
return;
}
try {
const result = await api('/api/admin/class-skills', {
method: 'POST',
body: JSON.stringify({
class_id: classId,
skill_id: skillId,
unlock_level: unlockLevel,
choice_group: choiceGroup,
custom_name: customName || null
})
});
// Add to local array immediately (optimistic update)
const skill = allSkills.find(s => s.id === skillId);
currentClassSkills.push({
id: result?.id || Date.now(), // Use response ID or temp ID
class_id: classId,
skill_id: skillId,
skill_name: skill?.name || skillId,
unlock_level: unlockLevel,
choice_group: choiceGroup,
custom_name: customName || null
});
showToast('Skill added');
renderClassSkills();
loadClassSkills(classId); // Background refresh for consistency
document.getElementById('addClassSkillSelect').value = '';
document.getElementById('addClassSkillName').value = '';
document.getElementById('addClassSkillLevel').value = '1';
document.getElementById('addClassSkillChoiceGroup').value = '';
} catch (e) {
showToast('Failed to add skill: ' + e.message, 'error');
}
}
async function updateClassSkill(id, field, value) {
try {
const data = {};
if (field === 'choice_group') {
data[field] = value === '' || value === null ? null : parseInt(value);
} else if (field === 'unlock_level') {
data[field] = parseInt(value) || 1;
} else {
data[field] = value || null;
}
await api(`/api/admin/class-skills/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Update local state immediately
const cs = currentClassSkills.find(s => s.id === id);
if (cs) cs[field] = data[field];
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeClassSkill(id) {
try {
await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' });
// Remove from local array immediately
currentClassSkills = currentClassSkills.filter(cs => cs.id !== id);
showToast('Skill removed');
renderClassSkills();
const classId = document.getElementById('classEditId').value;
loadClassSkills(classId); // Background refresh for consistency
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
// ============= USERS =============
async function loadUsers() {
try {
const data = await api('/api/admin/users');
users = data.users || [];
renderUserTable();
} catch (e) {
showToast('Failed to load users: ' + e.message, 'error');
}
}
function renderUserTable() {
const tbody = document.getElementById('userTableBody');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7">No users found</td></tr>';
return;
}
tbody.innerHTML = users.map(u => `
<tr>
<td><strong>${escapeHtml(u.username)}</strong></td>
<td>${u.character_name ? escapeHtml(u.character_name) : '<em>No character</em>'}</td>
<td>${u.race || '-'} / ${u.class || '-'}</td>
<td><span class="badge badge-level">Lv ${u.level || 1}</span></td>
<td>
<small>HP: ${u.hp || 0}/${u.max_hp || 0} | MP: ${u.mp || 0}/${u.max_mp || 0}</small><br>
<small>ATK: ${u.atk || 0} | DEF: ${u.def || 0} | XP: ${u.xp || 0}</small>
</td>
<td>
<span class="badge ${u.is_admin ? 'badge-admin' : 'badge-user'}">
${u.is_admin ? 'Admin' : 'User'}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-small" onclick="editUser(${u.id})">Edit</button>
<button class="btn btn-secondary btn-small" onclick="toggleAdmin(${u.id}, ${!u.is_admin})">
${u.is_admin ? 'Revoke Admin' : 'Grant Admin'}
</button>
</td>
</tr>
`).join('');
}
async function toggleAdmin(id, isAdmin) {
const action = isAdmin ? 'grant admin to' : 'revoke admin from';
if (!confirm(`Are you sure you want to ${action} this user?`)) return;
try {
await api(`/api/admin/users/${id}/admin`, {
method: 'PUT',
body: JSON.stringify({ is_admin: isAdmin })
});
// Update local array immediately (optimistic update)
const idx = users.findIndex(u => u.id == id);
if (idx !== -1) {
users[idx].is_admin = isAdmin;
}
showToast(isAdmin ? 'Admin granted' : 'Admin revoked');
renderUserTable();
loadUsers(); // Background refresh for consistency
} catch (e) {
showToast('Failed to update admin status: ' + e.message, 'error');
}
}
function editUser(id) {
const user = users.find(u => u.id === id);
if (!user) return;
document.getElementById('userId').value = user.id;
document.getElementById('userUsername').value = user.username;
document.getElementById('userCharacterName').value = user.character_name || '';
document.getElementById('userLevel').value = user.level || 1;
document.getElementById('userXp').value = user.xp || 0;
document.getElementById('userHp').value = user.hp || 0;
document.getElementById('userMaxHp').value = user.max_hp || 0;
document.getElementById('userMp').value = user.mp || 0;
document.getElementById('userMaxMp').value = user.max_mp || 0;
document.getElementById('userAtk').value = user.atk || 0;
document.getElementById('userDef').value = user.def || 0;
document.getElementById('userModal').classList.add('active');
}
function closeUserModal() {
document.getElementById('userModal').classList.remove('active');
}
async function resetUserProgress() {
const id = document.getElementById('userId').value;
if (!confirm('Are you sure you want to reset this user\'s RPG progress? This cannot be undone.')) return;
try {
await api(`/api/admin/users/${id}/reset`, { method: 'DELETE' });
showToast('User progress reset');
closeUserModal();
loadUsers();
} catch (e) {
showToast('Failed to reset progress: ' + e.message, 'error');
}
}
async function resetUserHomeBase() {
const id = document.getElementById('userId').value;
if (!confirm('Are you sure you want to reset this user\'s home base? They will need to set a new one.')) return;
try {
await api(`/api/admin/users/${id}/home-base`, { method: 'DELETE' });
showToast('Home base reset');
closeUserModal();
loadUsers();
} catch (e) {
showToast('Failed to reset home base: ' + e.message, 'error');
}
}
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('userId').value;
const data = {
character_name: document.getElementById('userCharacterName').value,
level: parseInt(document.getElementById('userLevel').value),
xp: parseInt(document.getElementById('userXp').value),
hp: parseInt(document.getElementById('userHp').value),
max_hp: parseInt(document.getElementById('userMaxHp').value),
mp: parseInt(document.getElementById('userMp').value),
max_mp: parseInt(document.getElementById('userMaxMp').value),
atk: parseInt(document.getElementById('userAtk').value),
def: parseInt(document.getElementById('userDef').value)
};
try {
await api(`/api/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
// Update local array immediately (optimistic update)
const idx = users.findIndex(u => u.id == id);
if (idx !== -1) {
users[idx] = { ...users[idx], ...data };
}
showToast('User updated');
renderUserTable();
closeUserModal();
loadUsers(); // Background refresh for consistency
} catch (e) {
showToast('Failed to update user: ' + e.message, 'error');
}
});
// ============= SETTINGS =============
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
settings = data.settings || {};
// Populate form (convert interval from ms to seconds for display)
const intervalMs = settings.monsterSpawnInterval || 20000;
document.getElementById('setting-monsterSpawnInterval').value = Math.round(intervalMs / 1000);
document.getElementById('setting-monsterSpawnChance').value = settings.monsterSpawnChance || 50;
document.getElementById('setting-monsterSpawnDistance').value = settings.monsterSpawnDistance || 10;
document.getElementById('setting-maxMonstersPerPlayer').value = settings.maxMonstersPerPlayer || 10;
document.getElementById('setting-xpMultiplier').value = settings.xpMultiplier || 1.0;
document.getElementById('setting-combatEnabled').checked = settings.combatEnabled !== 'false' && settings.combatEnabled !== false;
document.getElementById('setting-mpRegenDistance').value = settings.mpRegenDistance || 5;
document.getElementById('setting-mpRegenAmount').value = settings.mpRegenAmount || 1;
// HP Regen settings (convert interval from ms to seconds)
const hpIntervalMs = settings.hpRegenInterval || 10000;
document.getElementById('setting-hpRegenInterval').value = Math.round(hpIntervalMs / 1000);
document.getElementById('setting-hpRegenPercent').value = settings.hpRegenPercent || 1;
// Home base settings
document.getElementById('setting-homeHpMultiplier').value = settings.homeHpMultiplier || 3;
document.getElementById('setting-homeRegenPercent').value = settings.homeRegenPercent || 5;
document.getElementById('setting-homeBaseRadius').value = settings.homeBaseRadius || 20;
// Session settings (convert inactivity timeout from ms to minutes)
const inactivityMs = settings.inactivityTimeout || 600000;
document.getElementById('setting-inactivityTimeout').value = Math.round(inactivityMs / 60000);
const warningMs = settings.inactivityWarningTime || 60000;
document.getElementById('setting-inactivityWarningTime').value = Math.round(warningMs / 1000);
} catch (e) {
showToast('Failed to load settings: ' + e.message, 'error');
}
}
document.getElementById('saveSettingsBtn').addEventListener('click', async () => {
// Convert interval from seconds to ms for storage
const intervalSeconds = parseInt(document.getElementById('setting-monsterSpawnInterval').value) || 20;
const hpIntervalSeconds = parseInt(document.getElementById('setting-hpRegenInterval').value) || 10;
// Convert inactivity timeout from minutes to ms, warning from seconds to ms
const inactivityMinutes = parseInt(document.getElementById('setting-inactivityTimeout').value) || 10;
const warningSeconds = parseInt(document.getElementById('setting-inactivityWarningTime').value) || 60;
const newSettings = {
monsterSpawnInterval: intervalSeconds * 1000,
monsterSpawnChance: parseInt(document.getElementById('setting-monsterSpawnChance').value) || 50,
monsterSpawnDistance: parseInt(document.getElementById('setting-monsterSpawnDistance').value) || 10,
maxMonstersPerPlayer: parseInt(document.getElementById('setting-maxMonstersPerPlayer').value) || 10,
xpMultiplier: parseFloat(document.getElementById('setting-xpMultiplier').value) || 1.0,
combatEnabled: document.getElementById('setting-combatEnabled').checked,
mpRegenDistance: parseInt(document.getElementById('setting-mpRegenDistance').value) || 5,
mpRegenAmount: parseInt(document.getElementById('setting-mpRegenAmount').value) || 1,
hpRegenInterval: hpIntervalSeconds * 1000,
hpRegenPercent: parseFloat(document.getElementById('setting-hpRegenPercent').value) || 1,
homeHpMultiplier: parseFloat(document.getElementById('setting-homeHpMultiplier').value) || 3,
homeRegenPercent: parseFloat(document.getElementById('setting-homeRegenPercent').value) || 5,
homeBaseRadius: parseInt(document.getElementById('setting-homeBaseRadius').value) || 20,
inactivityTimeout: inactivityMinutes * 60000,
inactivityWarningTime: warningSeconds * 1000
};
try {
await api('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify(newSettings)
});
// Update local settings immediately
settings = { ...settings, ...newSettings };
showToast('Settings saved');
loadSettings(); // Background refresh for consistency
} catch (e) {
showToast('Failed to save settings: ' + e.message, 'error');
}
});
// ============= UTILITIES =============
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Initialize
checkAuth();
</script>
</body>
</html>