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.
 
 
 
 
 

3662 lines
160 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 select option,
select option {
background: #2d2d2d;
color: #fff;
}
.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;
background: rgba(255,255,255,0.05);
color: #fff;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 8px;
}
.add-skill-row select option {
background: #2d2d2d;
color: #fff;
}
.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 .skill-animation {
width: 100px;
padding: 4px;
font-size: 12px;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
color: inherit;
}
.monster-skill-item label {
font-size: 11px;
color: #888;
}
.skill-icon-btn {
width: 32px;
height: 32px;
padding: 0;
border: 2px dashed #555;
border-radius: 6px;
background: rgba(255,255,255,0.05);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.skill-icon-btn:hover {
border-color: #4CAF50;
background: rgba(76, 175, 80, 0.1);
}
.skill-icon-btn.has-icon {
border-style: solid;
border-color: #4CAF50;
}
.skill-icon-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
.skill-icon-btn .icon-placeholder {
font-size: 14px;
color: #666;
}
.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;
}
/* Animation preview styles */
.animation-preview-container {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 150px;
margin-bottom: 15px;
}
.animation-preview-icon {
width: 100px;
height: 100px;
object-fit: contain;
}
.animation-test-row {
display: flex;
gap: 10px;
align-items: center;
}
.animation-test-row select {
flex: 1;
background: rgba(255,255,255,0.05);
color: #fff;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 8px;
}
.animation-test-row select option {
background: #2d2d2d;
color: #fff;
}
/* Monster skill inline selects */
.monster-skill-item select,
.skill-animation {
background: rgba(255,255,255,0.05);
color: #fff;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 4px 8px;
}
.monster-skill-item select option,
.skill-animation option {
background: #2d2d2d;
color: #fff;
}
</style>
<!-- Monster Animation Definitions -->
<script src="/animations.js"></script>
</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>
<a class="nav-item" data-section="osm-tags">
<span class="icon">&#127759;</span> OSM Tags
</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>
<h3 style="margin: 20px 0 10px; color: #666; font-size: 14px;">Combat UI Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Combat Monster Icon Scale (Mobile)</label>
<input type="number" id="setting-combatIconScale" placeholder="0.7" min="0.3" max="1.5" step="0.1">
<small style="color: #888; font-size: 11px;">Scale for monster icons on mobile only (0.3 = 30px, 0.7 = 70px, 1.0 = 100px). Desktop always uses 100px.</small>
</div>
<div class="form-group">
<!-- empty for alignment -->
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button>
</div>
</section>
<!-- OSM Tags Section -->
<section id="osm-tags-section" class="section">
<div class="section-header">
<h2>OSM Tag Prefixes</h2>
<button class="btn btn-primary" id="addOsmTagBtn">+ Add Tag</button>
</div>
<!-- Global Prefix Settings -->
<div class="settings-card" style="margin-bottom: 30px;">
<h3>Prefix Settings</h3>
<div class="form-row">
<div class="form-group">
<label>Base Prefix Chance (%)</label>
<input type="number" id="osm-basePrefixChance" min="0" max="100" value="25">
<small style="color: #888; font-size: 11px;">Chance to spawn with any prefix when in a tag zone</small>
</div>
<div class="form-group">
<label>Double Prefix Chance (%)</label>
<input type="number" id="osm-doublePrefixChance" min="0" max="100" value="10">
<small style="color: #888; font-size: 11px;">Chance for two prefixes when eligible</small>
</div>
</div>
<button class="btn btn-primary" id="saveOsmSettingsBtn">Save Prefix Settings</button>
</div>
<!-- Tags Table -->
<table class="data-table" id="osmTagTable">
<thead>
<tr>
<th>Tag ID</th>
<th>Artwork</th>
<th>Prefixes</th>
<th>Visibility</th>
<th>Spawn Radius</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="osmTagTableBody">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</section>
</main>
</div>
<!-- OSM Tag Edit Modal -->
<div class="modal-overlay" id="osmTagModal">
<div class="modal">
<div class="modal-header">
<h3 id="osmTagModalTitle">Edit OSM Tag</h3>
<button class="modal-close" onclick="closeOsmTagModal()">&times;</button>
</div>
<form id="osmTagForm">
<input type="hidden" id="osmTagIdField">
<div class="form-group">
<label>Tag ID</label>
<input type="text" id="osmTagIdInput" required placeholder="e.g., grocery, restaurant">
<small style="color: #888; font-size: 11px;">Matches geocache tags array values</small>
</div>
<div class="form-row">
<div class="form-group">
<label>Artwork Number</label>
<input type="number" id="osmTagArtwork" value="1" min="1" placeholder="1" onchange="updateArtworkPreview()">
<small style="color: #888; font-size: 11px;">Maps to cacheIcon100-{number}.png</small>
<div id="artworkPreviewContainer" style="margin-top: 8px; position: relative; width: 80px; height: 80px;">
<img id="artworkPreviewShadow" src="" style="position: absolute; width: 64px; height: 64px; top: 8px; left: 8px; opacity: 0.5; display: none;">
<img id="artworkPreview" src="" style="position: absolute; width: 64px; height: 64px; top: 4px; left: 4px;">
</div>
</div>
<div class="form-group">
<label>Main Animation</label>
<select id="osmTagAnimation">
<option value="">None</option>
</select>
<label style="margin-top: 10px;">Shadow Animation</label>
<select id="osmTagAnimationShadow">
<option value="">None</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Visibility Distance (m)</label>
<input type="number" id="osmTagVisibility" value="400" min="50">
</div>
<div class="form-group">
<label>Spawn Radius (m)</label>
<input type="number" id="osmTagSpawnRadius" value="400" min="50">
</div>
</div>
<div class="form-group">
<label>Prefixes (one per line)</label>
<textarea id="osmTagPrefixes" rows="6" placeholder="Cart Wranglin'&#10;Stock-Boy&#10;Aisle Runner"></textarea>
<small style="color: #888; font-size: 11px;">Each prefix on its own line. Tag is enabled when at least one prefix exists.</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeOsmTagModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</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>Spawn Location</label>
<select id="monsterSpawnLocation">
<option value="anywhere">Anywhere</option>
<option value="grocery">Grocery Stores</option>
<option value="restaurant">Restaurants</option>
<option value="fastfood">Fast Food</option>
<option value="park">Parks</option>
<option value="bank">Banks</option>
<option value="cafe">Cafes</option>
<option value="bar">Bars</option>
<option value="pharmacy">Pharmacies</option>
<option value="convenience">Convenience Stores</option>
<option value="gasstation">Gas Stations</option>
</select>
</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="animation-section">
<h4>Animation Overrides</h4>
<div class="form-row-3">
<div class="form-group">
<label>Attack Animation</label>
<select id="monsterAttackAnim">
<!-- Populated dynamically -->
</select>
</div>
<div class="form-group">
<label>Death Animation</label>
<select id="monsterDeathAnim">
<!-- Populated dynamically -->
</select>
</div>
<div class="form-group">
<label>Idle Animation</label>
<select id="monsterIdleAnim">
<!-- Populated dynamically -->
</select>
</div>
</div>
<h4>Animation Preview</h4>
<div class="animation-preview-container">
<img id="animPreviewIcon" class="animation-preview-icon" src="/mapgameimgs/monsters/default100.png" alt="Preview">
</div>
<div class="animation-test-row">
<select id="testAnimationSelect">
<!-- Populated dynamically -->
</select>
<button type="button" class="btn btn-secondary" onclick="testMonsterAnimation()">Test Animation</button>
</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="form-row-2" id="targetingModeRow">
<div class="form-group">
<label>Targeting Mode (Multi-Hit)</label>
<select id="skillTargetingMode">
<option value="same_target">Same Target (all hits on selected enemy)</option>
<option value="per_hit">Per-Hit Selection (player chooses each hit)</option>
<option value="random">Random (each hit targets random enemy)</option>
</select>
<small style="color: #666; font-size: 11px;">Only applies when Hit Count > 1 and multiple enemies present</small>
</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>
<!-- Skill Icon Upload -->
<div class="skill-icon-section" style="margin-top: 15px; padding: 15px; border: 1px solid #333; border-radius: 8px;">
<h4 style="margin: 0 0 10px 0; color: #4CAF50;">Skill Icon</h4>
<div style="display: flex; align-items: center; gap: 20px;">
<div id="skillIconPreview" style="width: 64px; height: 64px; border: 2px dashed #555; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: #1a1a1a; overflow: hidden;">
<span style="color: #666; font-size: 11px;">No icon</span>
</div>
<div>
<input type="file" id="skillIconFile" accept="image/png,image/jpeg,image/gif,image/webp" style="display: none;">
<button type="button" class="btn btn-secondary" style="margin-bottom: 5px;" onclick="document.getElementById('skillIconFile').click()">Upload Icon</button>
<button type="button" class="btn btn-danger" id="removeSkillIconBtn" style="display: none; margin-left: 5px;" onclick="removeSkillIcon()">Remove</button>
<p style="font-size: 11px; color: #666; margin: 5px 0 0 0;">Recommended: 64x64 PNG. Max 500KB. Will be auto-renamed to skill ID.</p>
</div>
</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
let pendingSkillIcon = null; // Pending icon file for upload
// 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;
}
// Build animation options once
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animOptions = '<option value="">Default</option>' + Object.entries(animations).map(([id, anim]) =>
`<option value="${id}">${anim.name}</option>`
).join('');
const monsterId = document.getElementById('monsterId').value;
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;
const currentAnim = ms.animation || '';
const hasIcon = !!ms.custom_icon;
const iconSrc = hasIcon ? `/mapgameimgs/skills/${ms.custom_icon}` : '';
return `
<div class="monster-skill-item" data-id="${ms.id}">
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
onclick="uploadMonsterSkillIcon('${monsterId}', '${ms.skill_id}')"
title="Click to upload custom icon">
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
</button>
<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>
<div>
<label>Anim</label>
<select class="skill-animation" onchange="updateMonsterSkillAnimation(${ms.id}, this.value)">
${animOptions.replace(`value="${currentAnim}"`, `value="${currentAnim}" selected`)}
</select>
</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 updateMonsterSkillAnimation(id, value) {
try {
await api(`/api/admin/monster-skills/${id}`, {
method: 'PUT',
body: JSON.stringify({ animation: value || null })
});
// Update local state
const ms = currentMonsterSkills.find(s => s.id === id);
if (ms) ms.animation = value || null;
} catch (e) {
showToast('Failed to update skill animation: ' + 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('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
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');
// Set animation overrides
populateAnimationDropdowns();
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
// Update preview icon
document.getElementById('animPreviewIcon').src = `/mapgameimgs/monsters/${monster.key}100.png`;
document.getElementById('animPreviewIcon').onerror = function() { this.src = '/mapgameimgs/monsters/default100.png'; };
// 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('monsterSpawnLocation').value = monster.spawn_location || 'anywhere';
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');
// Copy animation settings
populateAnimationDropdowns();
document.getElementById('monsterAttackAnim').value = monster.attack_animation || 'attack';
document.getElementById('monsterDeathAnim').value = monster.death_animation || 'death';
document.getElementById('monsterIdleAnim').value = monster.idle_animation || 'idle';
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
// 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;
// Set default animations
populateAnimationDropdowns();
document.getElementById('monsterAttackAnim').value = 'attack';
document.getElementById('monsterDeathAnim').value = 'death';
document.getElementById('monsterIdleAnim').value = 'idle';
document.getElementById('animPreviewIcon').src = '/mapgameimgs/monsters/default100.png';
// 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');
}
// Populate animation dropdowns from MONSTER_ANIMATIONS
function populateAnimationDropdowns() {
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animIds = Object.keys(animations);
const dropdowns = ['monsterAttackAnim', 'monsterDeathAnim', 'monsterIdleAnim', 'testAnimationSelect'];
dropdowns.forEach(dropdownId => {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
dropdown.innerHTML = animIds.map(id => {
const anim = animations[id];
return `<option value="${id}">${anim.name} - ${anim.description}</option>`;
}).join('');
});
}
// Populate a single animation dropdown (for OSM tags, etc.)
function populateAnimationDropdown(dropdownId) {
const dropdown = document.getElementById(dropdownId);
if (!dropdown) return;
const animations = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS : {};
const animIds = Object.keys(animations);
// Keep None option, add animation options
dropdown.innerHTML = '<option value="">None</option>' + animIds.map(id => {
const anim = animations[id];
return `<option value="${id}">${anim.name}</option>`;
}).join('');
}
// Update artwork preview in OSM tag modal
function updateArtworkPreview() {
const artworkNum = parseInt(document.getElementById('osmTagArtwork').value) || 1;
const padNum = String(artworkNum).padStart(2, '0');
const basePath = '/mapgameimgs/cacheicons/cacheIcon100-';
const mainImg = document.getElementById('artworkPreview');
const shadowImg = document.getElementById('artworkPreviewShadow');
if (mainImg) {
mainImg.src = `${basePath}${padNum}.png`;
mainImg.onerror = function() { this.style.display = 'none'; };
mainImg.onload = function() { this.style.display = 'block'; };
}
if (shadowImg) {
shadowImg.src = `${basePath}${padNum}_shadow.png`;
shadowImg.onerror = function() { this.style.display = 'none'; };
shadowImg.onload = function() { this.style.display = 'block'; };
}
}
// Test animation preview
function testMonsterAnimation() {
const animId = document.getElementById('testAnimationSelect').value;
const previewIcon = document.getElementById('animPreviewIcon');
if (!previewIcon || !animId) return;
const anim = typeof MONSTER_ANIMATIONS !== 'undefined' ? MONSTER_ANIMATIONS[animId] : null;
if (!anim) {
showToast('Animation not found', 'error');
return;
}
// Reset animation
previewIcon.style.animation = 'none';
previewIcon.offsetHeight; // Force reflow
// Apply animation
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
previewIcon.style.animation = `monster_${animId} ${anim.duration}ms ${easing}${loopStr}${fillStr}`;
}
// Generate animation CSS for preview
function generateAdminAnimationCSS() {
if (typeof MONSTER_ANIMATIONS === 'undefined') return;
let css = '';
for (const [id, anim] of Object.entries(MONSTER_ANIMATIONS)) {
const loopStr = anim.loop ? ' infinite' : '';
const fillStr = anim.fillMode ? ` ${anim.fillMode}` : '';
const easing = anim.easing || 'ease-out';
css += `@keyframes monster_${id} { ${anim.keyframes} }\n`;
}
const style = document.createElement('style');
style.id = 'monster-animations-css';
style.textContent = css;
document.head.appendChild(style);
}
// Initialize animation CSS on page load
document.addEventListener('DOMContentLoaded', generateAdminAnimationCSS);
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),
spawn_location: document.getElementById('monsterSpawnLocation').value,
levelScale: { mp: parseInt(document.getElementById('monsterMpScale').value) || 5 },
enabled: document.getElementById('monsterEnabled').checked,
attack_animation: document.getElementById('monsterAttackAnim').value,
death_animation: document.getElementById('monsterDeathAnim').value,
idle_animation: document.getElementById('monsterIdleAnim').value,
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('skillTargetingMode').value = skill.targeting_mode || 'same_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();
// Load existing icon preview
if (skill.icon) {
updateSkillIconPreview(`/mapgameimgs/skills/${skill.icon}`);
} else {
resetSkillIconPreview();
}
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;
document.getElementById('skillTargetingMode').value = 'same_target';
// Reset to default (damage) type and toggle visibility
document.getElementById('skillType').value = 'damage';
handleSkillTypeChange();
resetSkillIconPreview();
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();
resetSkillIconPreview();
// 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;
pendingSkillIcon = null;
}
// Skill icon helper functions
function updateSkillIconPreview(src) {
const preview = document.getElementById('skillIconPreview');
const removeBtn = document.getElementById('removeSkillIconBtn');
if (src) {
preview.innerHTML = `<img src="${src}" style="max-width: 100%; max-height: 100%; object-fit: contain;">`;
removeBtn.style.display = 'inline-block';
} else {
preview.innerHTML = '<span style="color: #666; font-size: 11px;">No icon</span>';
removeBtn.style.display = 'none';
}
}
function resetSkillIconPreview() {
pendingSkillIcon = null;
updateSkillIconPreview(null);
}
async function uploadSkillIcon(skillId) {
if (!pendingSkillIcon) return;
const formData = new FormData();
formData.append('icon', pendingSkillIcon);
try {
const response = await fetch(`/api/admin/skills/${skillId}/icon`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: formData
});
if (!response.ok) throw new Error('Upload failed');
pendingSkillIcon = null;
} catch (err) {
console.error('Icon upload error:', err);
showToast('Failed to upload icon', 'error');
}
}
async function removeSkillIcon() {
const skillId = document.getElementById('skillEditId').value;
if (skillId) {
try {
await api(`/api/admin/skills/${skillId}/icon`, { method: 'DELETE' });
// Update local data
const skill = allSkills.find(s => s.id === skillId);
if (skill) skill.icon = null;
} catch (err) {
showToast('Failed to remove icon', 'error');
return;
}
}
pendingSkillIcon = null;
updateSkillIconPreview(null);
}
// File input change listener
document.getElementById('skillIconFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
if (file.size > 500 * 1024) {
showToast('Icon file must be under 500KB', 'error');
return;
}
const reader = new FileReader();
reader.onload = (e) => updateSkillIconPreview(e.target.result);
reader.readAsDataURL(file);
pendingSkillIcon = file;
});
// Monster skill icon upload
async function uploadMonsterSkillIcon(monsterTypeId, skillId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 500 * 1024) {
showToast('Icon must be under 500KB', 'error');
return;
}
const formData = new FormData();
formData.append('icon', file);
try {
const response = await fetch(`/api/admin/monster-skills/${monsterTypeId}/${skillId}/icon`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
// Update local data
const ms = currentMonsterSkills.find(s => s.skill_id === skillId);
if (ms) ms.custom_icon = result.icon;
renderMonsterSkills();
showToast('Icon uploaded');
} catch (err) {
console.error('Monster skill icon upload error:', err);
showToast('Failed to upload icon', 'error');
}
};
input.click();
}
// Class skill icon upload
async function uploadClassSkillIcon(classId, skillId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 500 * 1024) {
showToast('Icon must be under 500KB', 'error');
return;
}
const formData = new FormData();
formData.append('icon', file);
try {
const response = await fetch(`/api/admin/class-skills/${classId}/${skillId}/icon`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: formData
});
if (!response.ok) throw new Error('Upload failed');
const result = await response.json();
// Update local data
const cs = currentClassSkills.find(s => s.skill_id === skillId);
if (cs) cs.custom_icon = result.icon;
renderClassSkills();
showToast('Icon uploaded');
} catch (err) {
console.error('Class skill icon upload error:', err);
showToast('Failed to upload icon', 'error');
}
};
input.click();
}
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,
targeting_mode: document.getElementById('skillTargetingMode').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 {
let skillId;
if (editId) {
await api(`/api/admin/skills/${editId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
skillId = editId;
// 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 {
const result = await api('/api/admin/skills', {
method: 'POST',
body: JSON.stringify(data)
});
skillId = result.id || data.id;
// Add to local array immediately
allSkills.push({ ...data, id: skillId });
showToast('Skill created');
}
// Upload icon if pending
if (pendingSkillIcon && skillId) {
await uploadSkillIcon(skillId);
}
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;
}
const classId = document.getElementById('classEditId').value;
// 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>';
const hasIcon = !!cs.custom_icon;
const iconSrc = hasIcon ? `/mapgameimgs/skills/${cs.custom_icon}` : '';
html += `
<div class="monster-skill-item" data-id="${cs.id}">
<button type="button" class="skill-icon-btn ${hasIcon ? 'has-icon' : ''}"
onclick="uploadClassSkillIcon('${classId}', '${cs.skill_id}')"
title="Click to upload custom icon">
${hasIcon ? `<img src="${iconSrc}" onerror="this.parentElement.innerHTML='<span class=\\'icon-placeholder\\'>🖼</span>'">` : '<span class="icon-placeholder">🖼</span>'}
</button>
<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>
<button class="btn btn-danger btn-small" onclick="deleteUser(${u.id}, '${escapeHtml(u.username).replace(/'/g, "\\'")}')">Delete</button>
</td>
</tr>
`).join('');
}
async function deleteUser(id, username) {
if (!confirm(`Are you sure you want to DELETE user "${username}"?\n\nThis will permanently remove:\n- User account\n- Character data\n- Monsters\n- Buffs\n- All progress\n\nThis action cannot be undone!`)) return;
// Double confirm for safety
if (!confirm(`FINAL WARNING: Delete user "${username}" permanently?`)) return;
try {
await api(`/api/admin/users/${id}`, { method: 'DELETE' });
// Remove from local array immediately
users = users.filter(u => u.id !== id);
showToast(`User "${username}" deleted`);
renderUserTable();
} catch (e) {
showToast('Failed to delete user: ' + e.message, 'error');
}
}
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);
// Combat UI settings
document.getElementById('setting-combatIconScale').value = settings.combatIconScale || 1.0;
} 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,
combatIconScale: parseFloat(document.getElementById('setting-combatIconScale').value) || 1.0
};
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');
}
});
// ============= OSM TAGS =============
let osmTags = [];
let osmTagSettings = { basePrefixChance: 25, doublePrefixChance: 10 };
async function loadOsmTags() {
try {
const response = await api('/api/admin/osm-tags');
osmTags = response.osmTags || [];
renderOsmTagTable();
} catch (e) {
console.error('Failed to load OSM tags:', e);
showToast('Failed to load OSM tags', 'error');
}
}
async function loadOsmTagSettings() {
try {
const response = await api('/api/admin/osm-tag-settings');
osmTagSettings = response;
document.getElementById('osm-basePrefixChance').value = osmTagSettings.basePrefixChance || 25;
document.getElementById('osm-doublePrefixChance').value = osmTagSettings.doublePrefixChance || 10;
} catch (e) {
console.error('Failed to load OSM tag settings:', e);
}
}
function renderOsmTagTable() {
const tbody = document.getElementById('osmTagTableBody');
if (osmTags.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; color:#888;">No OSM tags configured</td></tr>';
return;
}
tbody.innerHTML = osmTags.map(tag => {
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
const prefixCount = prefixes.length;
const prefixPreview = prefixes.slice(0, 2).join(', ') + (prefixes.length > 2 ? '...' : '');
const artworkNum = String(tag.artwork || 1).padStart(2, '0');
return `
<tr>
<td><strong>${escapeHtml(tag.id)}</strong></td>
<td>${artworkNum}${tag.animation ? ` <small style="color:#888">(${tag.animation})</small>` : ''}</td>
<td title="${escapeHtml(prefixes.join(', '))}">
${prefixCount > 0 ? `<span style="color:#4CAF50">${prefixCount} prefix${prefixCount > 1 ? 'es' : ''}</span>` : '<span style="color:#888">None</span>'}
${prefixPreview ? `<br><small style="color:#666">${escapeHtml(prefixPreview)}</small>` : ''}
</td>
<td>${tag.visibility_distance}m</td>
<td>${tag.spawn_radius}m</td>
<td>
<span class="status ${tag.enabled ? 'enabled' : 'disabled'}">
${tag.enabled ? 'Yes' : 'No'}
</span>
</td>
<td class="actions">
<button class="btn btn-small" onclick="editOsmTag('${escapeHtml(tag.id)}')">Edit</button>
<button class="btn btn-small btn-danger" onclick="deleteOsmTag('${escapeHtml(tag.id)}')">Delete</button>
</td>
</tr>
`;
}).join('');
}
function openOsmTagModal(tag = null) {
const modal = document.getElementById('osmTagModal');
const title = document.getElementById('osmTagModalTitle');
const form = document.getElementById('osmTagForm');
const idInput = document.getElementById('osmTagIdInput');
form.reset();
// Populate animation dropdowns from MONSTER_ANIMATIONS if available
populateAnimationDropdown('osmTagAnimation');
populateAnimationDropdown('osmTagAnimationShadow');
if (tag) {
title.textContent = 'Edit OSM Tag';
document.getElementById('osmTagIdField').value = tag.id;
idInput.value = tag.id;
idInput.readOnly = true;
document.getElementById('osmTagArtwork').value = tag.artwork || 1;
document.getElementById('osmTagAnimation').value = tag.animation || '';
document.getElementById('osmTagAnimationShadow').value = tag.animation_shadow || '';
document.getElementById('osmTagVisibility').value = tag.visibility_distance || 400;
document.getElementById('osmTagSpawnRadius').value = tag.spawn_radius || 400;
const prefixes = typeof tag.prefixes === 'string' ? JSON.parse(tag.prefixes || '[]') : (tag.prefixes || []);
document.getElementById('osmTagPrefixes').value = prefixes.join('\n');
} else {
title.textContent = 'Add OSM Tag';
document.getElementById('osmTagIdField').value = '';
idInput.readOnly = false;
document.getElementById('osmTagArtwork').value = 1;
document.getElementById('osmTagAnimation').value = '';
document.getElementById('osmTagAnimationShadow').value = '';
document.getElementById('osmTagVisibility').value = 400;
document.getElementById('osmTagSpawnRadius').value = 400;
document.getElementById('osmTagPrefixes').value = '';
}
// Update artwork preview
updateArtworkPreview();
modal.classList.add('active');
}
function closeOsmTagModal() {
document.getElementById('osmTagModal').classList.remove('active');
}
function editOsmTag(id) {
const tag = osmTags.find(t => t.id === id);
if (tag) {
openOsmTagModal(tag);
}
}
async function deleteOsmTag(id) {
if (!confirm(`Are you sure you want to delete the OSM tag "${id}"?`)) return;
try {
await api(`/api/admin/osm-tags/${id}`, { method: 'DELETE' });
osmTags = osmTags.filter(t => t.id !== id);
renderOsmTagTable();
showToast('OSM tag deleted');
} catch (e) {
showToast('Failed to delete OSM tag: ' + e.message, 'error');
}
}
document.getElementById('addOsmTagBtn').addEventListener('click', () => {
openOsmTagModal(null);
});
document.getElementById('osmTagForm').addEventListener('submit', async (e) => {
e.preventDefault();
const existingId = document.getElementById('osmTagIdField').value;
const newId = document.getElementById('osmTagIdInput').value.trim().toLowerCase();
const prefixesText = document.getElementById('osmTagPrefixes').value;
const prefixes = prefixesText.split('\n').map(p => p.trim()).filter(p => p.length > 0);
const data = {
id: newId,
artwork: parseInt(document.getElementById('osmTagArtwork').value) || 1,
animation: document.getElementById('osmTagAnimation').value || null,
animation_shadow: document.getElementById('osmTagAnimationShadow').value || null,
visibility_distance: parseInt(document.getElementById('osmTagVisibility').value) || 400,
spawn_radius: parseInt(document.getElementById('osmTagSpawnRadius').value) || 400,
prefixes: prefixes
};
try {
if (existingId) {
await api(`/api/admin/osm-tags/${existingId}`, {
method: 'PUT',
body: JSON.stringify(data)
});
showToast('OSM tag updated');
} else {
await api('/api/admin/osm-tags', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('OSM tag created');
}
closeOsmTagModal();
loadOsmTags();
} catch (e) {
showToast('Failed to save OSM tag: ' + e.message, 'error');
}
});
document.getElementById('saveOsmSettingsBtn').addEventListener('click', async () => {
const settings = {
basePrefixChance: parseInt(document.getElementById('osm-basePrefixChance').value) || 25,
doublePrefixChance: parseInt(document.getElementById('osm-doublePrefixChance').value) || 10
};
try {
await api('/api/admin/osm-tag-settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
osmTagSettings = settings;
showToast('Prefix settings saved');
} catch (e) {
showToast('Failed to save prefix 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();
loadOsmTags();
loadOsmTagSettings();
</script>
</body>
</html>