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.
1913 lines
72 KiB
1913 lines
72 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-small {
|
|
padding: 6px 12px;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Tables */
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.data-table th,
|
|
.data-table td {
|
|
padding: 14px 16px;
|
|
text-align: left;
|
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
|
|
.data-table th {
|
|
background: rgba(255,255,255,0.05);
|
|
font-weight: 600;
|
|
color: #aaa;
|
|
text-transform: uppercase;
|
|
font-size: 0.8rem;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.data-table tr:hover {
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
|
|
.data-table .actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Toggle Switch */
|
|
.toggle {
|
|
position: relative;
|
|
width: 50px;
|
|
height: 26px;
|
|
}
|
|
|
|
.toggle input {
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.toggle-slider {
|
|
position: absolute;
|
|
cursor: pointer;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255,255,255,0.2);
|
|
border-radius: 26px;
|
|
transition: 0.3s;
|
|
}
|
|
|
|
.toggle-slider:before {
|
|
position: absolute;
|
|
content: "";
|
|
height: 20px;
|
|
width: 20px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background: #fff;
|
|
border-radius: 50%;
|
|
transition: 0.3s;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider {
|
|
background: #4CAF50;
|
|
}
|
|
|
|
.toggle input:checked + .toggle-slider:before {
|
|
transform: translateX(24px);
|
|
}
|
|
|
|
/* Badges */
|
|
.badge {
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-admin {
|
|
background: rgba(255, 193, 7, 0.2);
|
|
color: #ffc107;
|
|
}
|
|
|
|
.badge-user {
|
|
background: rgba(100, 181, 246, 0.2);
|
|
color: #64b5f6;
|
|
}
|
|
|
|
.badge-level {
|
|
background: rgba(76, 175, 80, 0.2);
|
|
color: #4CAF50;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal {
|
|
background: #1a1a2e;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
width: 90%;
|
|
max-width: 600px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
font-size: 1.4rem;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: #aaa;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
/* Forms */
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,0.05);
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group select:focus,
|
|
.form-group textarea:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
}
|
|
|
|
.form-group textarea {
|
|
resize: vertical;
|
|
min-height: 100px;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.form-row-3 {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.form-row-4 {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 15px;
|
|
}
|
|
|
|
.modal-wide {
|
|
max-width: 700px;
|
|
}
|
|
|
|
.status-effect-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.status-effect-section h4 {
|
|
margin-bottom: 5px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Skill Type Badges */
|
|
.skill-type-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.skill-type-damage {
|
|
background: rgba(244, 67, 54, 0.2);
|
|
color: #f44336;
|
|
}
|
|
|
|
.skill-type-heal {
|
|
background: rgba(76, 175, 80, 0.2);
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.skill-type-buff {
|
|
background: rgba(33, 150, 243, 0.2);
|
|
color: #2196F3;
|
|
}
|
|
|
|
.skill-type-debuff {
|
|
background: rgba(156, 39, 176, 0.2);
|
|
color: #9C27B0;
|
|
}
|
|
|
|
.skill-type-status {
|
|
background: rgba(255, 152, 0, 0.2);
|
|
color: #FF9800;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
margin-top: 30px;
|
|
}
|
|
|
|
/* Settings Section */
|
|
.settings-card {
|
|
background: rgba(255,255,255,0.03);
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.settings-card h3 {
|
|
margin-bottom: 20px;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
/* Loading State */
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #aaa;
|
|
}
|
|
|
|
/* Error/Success Messages */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 30px;
|
|
right: 30px;
|
|
padding: 15px 25px;
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
font-weight: 500;
|
|
z-index: 2000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
.toast.success {
|
|
background: #4CAF50;
|
|
}
|
|
|
|
.toast.error {
|
|
background: #f44336;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* Dialogue Editor */
|
|
.dialogue-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.dialogue-section h4 {
|
|
margin-bottom: 15px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.dialogue-phase {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.dialogue-phase label {
|
|
font-weight: 600;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.dialogue-items {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.dialogue-item {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.dialogue-item input {
|
|
flex: 1;
|
|
}
|
|
|
|
.dialogue-item .btn {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
/* Skills Editor */
|
|
.skills-section {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.skills-section h4 {
|
|
margin-bottom: 15px;
|
|
color: #aaa;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.add-skill-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.add-skill-row select {
|
|
flex: 1;
|
|
}
|
|
|
|
.monster-skill-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
background: rgba(255,255,255,0.05);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.monster-skill-item .skill-name {
|
|
flex: 1;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.monster-skill-item .skill-weight,
|
|
.monster-skill-item .skill-min-level {
|
|
width: 60px;
|
|
text-align: center;
|
|
}
|
|
|
|
.monster-skill-item label {
|
|
font-size: 11px;
|
|
color: #888;
|
|
}
|
|
|
|
.skill-name-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.skill-custom-name {
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
font-size: 13px;
|
|
background: rgba(255,255,255,0.1);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
border-radius: 4px;
|
|
color: #fff;
|
|
}
|
|
|
|
.skill-custom-name:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
}
|
|
|
|
.skill-custom-name::placeholder {
|
|
color: #888;
|
|
font-style: italic;
|
|
}
|
|
|
|
.skill-base-name {
|
|
font-size: 10px;
|
|
color: #666;
|
|
}
|
|
|
|
/* Stats Display */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
|
gap: 10px;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.stat-item {
|
|
background: rgba(255,255,255,0.05);
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-item .value {
|
|
font-size: 1.2rem;
|
|
font-weight: bold;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.stat-item .label {
|
|
font-size: 0.75rem;
|
|
color: #aaa;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* Login Screen */
|
|
.login-screen {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
background: #0f0f23;
|
|
}
|
|
|
|
.login-box {
|
|
background: #1a1a2e;
|
|
padding: 40px;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.login-box h2 {
|
|
margin-bottom: 20px;
|
|
color: #4CAF50;
|
|
}
|
|
|
|
.login-box p {
|
|
color: #aaa;
|
|
margin-bottom: 20px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Login Screen (shown if not authenticated) -->
|
|
<div id="loginScreen" class="login-screen" style="display: none;">
|
|
<div class="login-box">
|
|
<h2>HikeMap Admin</h2>
|
|
<p>You must be logged in as an admin to access this page.</p>
|
|
<a href="/" class="btn btn-primary">Go to Login</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin Container -->
|
|
<div id="adminContainer" class="admin-container" style="display: none;">
|
|
<!-- Sidebar -->
|
|
<nav class="admin-sidebar">
|
|
<div class="admin-logo">HikeMap Admin</div>
|
|
<a class="nav-item active" data-section="monsters">
|
|
<span class="icon">👾</span> Monsters
|
|
</a>
|
|
<a class="nav-item" data-section="skills">
|
|
<span class="icon">⚡</span> Skills
|
|
</a>
|
|
<a class="nav-item" data-section="users">
|
|
<span class="icon">👤</span> Users
|
|
</a>
|
|
<a class="nav-item" data-section="settings">
|
|
<span class="icon">⚙</span> Settings
|
|
</a>
|
|
<div class="nav-spacer"></div>
|
|
<a class="nav-item" href="/">
|
|
<span class="icon">←</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>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>
|
|
</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>
|
|
<div class="form-actions">
|
|
<button class="btn btn-primary" id="saveSettingsBtn">Save Settings</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Monster Edit Modal -->
|
|
<div class="modal-overlay" id="monsterModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3 id="monsterModalTitle">Edit Monster</h3>
|
|
<button class="modal-close" onclick="closeMonsterModal()">×</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 XP</label>
|
|
<input type="number" id="monsterXp" required value="10" min="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spawn Weight (higher = more common)</label>
|
|
<input type="number" id="monsterWeight" required value="100" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="monsterEnabled" checked> Enabled
|
|
</label>
|
|
</div>
|
|
|
|
<div class="skills-section">
|
|
<h4>Monster Skills</h4>
|
|
<div id="monsterSkillsList"></div>
|
|
<div class="add-skill-row">
|
|
<select id="addSkillSelect" onchange="onSkillSelectChange()">
|
|
<option value="">-- Select a skill --</option>
|
|
</select>
|
|
<input type="text" id="addSkillCustomName" placeholder="Custom name (optional)" style="width: 150px;">
|
|
<input type="number" id="addSkillWeight" placeholder="Weight" value="10" min="1" style="width: 70px;">
|
|
<input type="number" id="addSkillMinLevel" placeholder="Min Lvl" value="1" min="1" style="width: 70px;">
|
|
<button type="button" class="btn btn-secondary btn-small" onclick="addMonsterSkill()">Add</button>
|
|
</div>
|
|
<p style="font-size: 11px; color: #666; margin-top: 5px;">Custom name overrides skill name for this monster. Weight = selection probability.</p>
|
|
</div>
|
|
|
|
<div class="dialogue-section">
|
|
<h4>Dialogues (one per line)</h4>
|
|
<div class="form-group">
|
|
<label>Annoyed (0-5 min)</label>
|
|
<textarea id="dialogueAnnoyed" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Frustrated (5-10 min)</label>
|
|
<textarea id="dialogueFrustrated" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Desperate (10-30 min)</label>
|
|
<textarea id="dialogueDesperate" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Philosophical (30-60 min)</label>
|
|
<textarea id="dialoguePhilosophical" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Existential (60+ min)</label>
|
|
<textarea id="dialogueExistential" placeholder="One dialogue line per line..."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeMonsterModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Monster</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- User Edit Modal -->
|
|
<div class="modal-overlay" id="userModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Edit User</h3>
|
|
<button class="modal-close" onclick="closeUserModal()">×</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-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()">×</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>
|
|
<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>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Target</label>
|
|
<select id="skillTarget">
|
|
<option value="enemy">Enemy</option>
|
|
<option value="self">Self</option>
|
|
<option value="all_enemies">All Enemies</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row-4">
|
|
<div class="form-group">
|
|
<label>MP Cost</label>
|
|
<input type="number" id="skillMpCost" value="0" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Base Power</label>
|
|
<input type="number" id="skillBasePower" value="100" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Accuracy (%)</label>
|
|
<input type="number" id="skillAccuracy" value="95" min="0" max="100">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Hit Count</label>
|
|
<input type="number" id="skillHitCount" value="1" min="1">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="status-effect-section">
|
|
<h4>Status Effect (optional)</h4>
|
|
<p style="font-size: 11px; color: #666; margin-bottom: 10px;">For skills that apply effects over time (poison, burn, etc.)</p>
|
|
<div class="form-row-3">
|
|
<div class="form-group">
|
|
<label>Effect Type</label>
|
|
<select id="skillStatusType">
|
|
<option value="">None</option>
|
|
<option value="poison">Poison</option>
|
|
<option value="burn">Burn</option>
|
|
<option value="bleed">Bleed</option>
|
|
<option value="regen">Regeneration</option>
|
|
<option value="stun">Stun</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Damage/Turn</label>
|
|
<input type="number" id="skillStatusDamage" value="5" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Duration (turns)</label>
|
|
<input type="number" id="skillStatusDuration" value="3" min="1">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row-3" style="margin-top: 15px;">
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillPlayerUsable" checked> Player Can Use
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillMonsterUsable" checked> Monster Can Use
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="skillEnabled" checked> Enabled
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-secondary" onclick="closeSkillModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Save Skill</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// State
|
|
let accessToken = localStorage.getItem('accessToken');
|
|
let monsters = [];
|
|
let users = [];
|
|
let settings = {};
|
|
let allSkills = [];
|
|
let currentMonsterSkills = []; // Skills for the monster being edited
|
|
|
|
// API Helper
|
|
async function api(endpoint, options = {}) {
|
|
const url = endpoint.startsWith('/') ? endpoint : '/' + endpoint;
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
...options.headers
|
|
}
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
showLoginScreen();
|
|
throw new Error('Not authorized');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'API error');
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Toast notifications
|
|
function showToast(message, type = 'success') {
|
|
const existing = document.querySelector('.toast');
|
|
if (existing) existing.remove();
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
// Show login screen
|
|
function showLoginScreen() {
|
|
document.getElementById('loginScreen').style.display = 'flex';
|
|
document.getElementById('adminContainer').style.display = 'none';
|
|
}
|
|
|
|
// Show admin container
|
|
function showAdminContainer() {
|
|
document.getElementById('loginScreen').style.display = 'none';
|
|
document.getElementById('adminContainer').style.display = 'flex';
|
|
}
|
|
|
|
// Check auth and admin status
|
|
async function checkAuth() {
|
|
if (!accessToken) {
|
|
showLoginScreen();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Try to fetch admin data - will fail if not admin
|
|
await api('/api/admin/settings');
|
|
showAdminContainer();
|
|
loadAllData();
|
|
} catch (e) {
|
|
showLoginScreen();
|
|
}
|
|
}
|
|
|
|
// Navigation
|
|
document.querySelectorAll('.nav-item[data-section]').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const section = item.dataset.section;
|
|
|
|
// Update nav
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
item.classList.add('active');
|
|
|
|
// Update sections
|
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
document.getElementById(`${section}-section`).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Load all data
|
|
async function loadAllData() {
|
|
loadMonsters();
|
|
loadUsers();
|
|
loadSettings();
|
|
loadSkillsAdmin(); // Load skills for admin (includes disabled)
|
|
}
|
|
|
|
// ============= MONSTERS =============
|
|
async function loadMonsters() {
|
|
try {
|
|
const data = await api('/api/admin/monster-types');
|
|
monsters = data.monsterTypes || [];
|
|
renderMonsterTable();
|
|
} catch (e) {
|
|
showToast('Failed to load monsters: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadSkills() {
|
|
try {
|
|
const data = await api('/api/skills');
|
|
allSkills = data.skills || [];
|
|
populateSkillSelect();
|
|
} catch (e) {
|
|
console.error('Failed to load skills:', e);
|
|
}
|
|
}
|
|
|
|
function populateSkillSelect() {
|
|
const select = document.getElementById('addSkillSelect');
|
|
select.innerHTML = '<option value="">-- Select a skill --</option>';
|
|
// Support both snake_case (admin API) and camelCase (public API)
|
|
allSkills.filter(s => s.monster_usable || s.monsterUsable).forEach(skill => {
|
|
const opt = document.createElement('option');
|
|
opt.value = skill.id;
|
|
opt.textContent = `${skill.name} (${skill.type})`;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
async function loadMonsterSkills(monsterTypeId) {
|
|
if (!monsterTypeId) {
|
|
currentMonsterSkills = [];
|
|
renderMonsterSkills();
|
|
return;
|
|
}
|
|
try {
|
|
const data = await api('/api/admin/monster-skills');
|
|
currentMonsterSkills = (data.monsterSkills || []).filter(ms => ms.monster_type_id === monsterTypeId);
|
|
renderMonsterSkills();
|
|
} catch (e) {
|
|
console.error('Failed to load monster skills:', e);
|
|
currentMonsterSkills = [];
|
|
renderMonsterSkills();
|
|
}
|
|
}
|
|
|
|
function renderMonsterSkills() {
|
|
const container = document.getElementById('monsterSkillsList');
|
|
if (currentMonsterSkills.length === 0) {
|
|
container.innerHTML = '<p style="color: #666; font-size: 12px;">No skills assigned. Monster will only use basic attack.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = currentMonsterSkills.map(ms => {
|
|
const skill = allSkills.find(s => s.id === ms.skill_id);
|
|
const baseName = skill ? skill.name : ms.skill_id;
|
|
const displayName = ms.custom_name || baseName;
|
|
const hasCustomName = !!ms.custom_name;
|
|
return `
|
|
<div class="monster-skill-item" data-id="${ms.id}">
|
|
<div class="skill-name-section">
|
|
<input type="text" class="skill-custom-name" value="${escapeHtml(ms.custom_name || '')}"
|
|
placeholder="${escapeHtml(baseName)}"
|
|
onchange="updateMonsterSkillName(${ms.id}, this.value)"
|
|
title="Custom name (leave empty for default: ${escapeHtml(baseName)})">
|
|
<span class="skill-base-name">${escapeHtml(baseName)}</span>
|
|
</div>
|
|
<div>
|
|
<label>Wt</label>
|
|
<input type="number" class="skill-weight" value="${ms.weight}" min="1"
|
|
onchange="updateMonsterSkill(${ms.id}, 'weight', this.value)">
|
|
</div>
|
|
<div>
|
|
<label>Lvl</label>
|
|
<input type="number" class="skill-min-level" value="${ms.min_level}" min="1"
|
|
onchange="updateMonsterSkill(${ms.id}, 'min_level', this.value)">
|
|
</div>
|
|
<button type="button" class="btn btn-danger btn-small" onclick="removeMonsterSkill(${ms.id})">✕</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function onSkillSelectChange() {
|
|
// Clear custom name when selecting a new skill
|
|
document.getElementById('addSkillCustomName').value = '';
|
|
}
|
|
|
|
async function addMonsterSkill() {
|
|
const monsterId = document.getElementById('monsterId').value;
|
|
const skillId = document.getElementById('addSkillSelect').value;
|
|
const customName = document.getElementById('addSkillCustomName').value.trim();
|
|
const weight = parseInt(document.getElementById('addSkillWeight').value) || 10;
|
|
const minLevel = parseInt(document.getElementById('addSkillMinLevel').value) || 1;
|
|
|
|
if (!skillId) {
|
|
showToast('Please select a skill', 'error');
|
|
return;
|
|
}
|
|
if (!monsterId) {
|
|
showToast('Please save the monster first before adding skills', 'error');
|
|
return;
|
|
}
|
|
|
|
// Check if skill already assigned
|
|
if (currentMonsterSkills.some(ms => ms.skill_id === skillId)) {
|
|
showToast('This skill is already assigned to this monster', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
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
|
|
})
|
|
});
|
|
showToast('Skill added');
|
|
await loadMonsterSkills(monsterId);
|
|
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) })
|
|
});
|
|
} catch (e) {
|
|
showToast('Failed to update skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function removeMonsterSkill(id) {
|
|
try {
|
|
await api(`/api/admin/monster-skills/${id}`, { method: 'DELETE' });
|
|
showToast('Skill removed');
|
|
const monsterId = document.getElementById('monsterId').value;
|
|
await loadMonsterSkills(monsterId);
|
|
} 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="9">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_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' });
|
|
showToast('Monster deleted');
|
|
loadMonsters();
|
|
} 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('monsterXp').value = monster.base_xp;
|
|
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
|
|
document.getElementById('monsterEnabled').checked = monster.enabled;
|
|
|
|
// Parse dialogues
|
|
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
|
|
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
|
|
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
|
|
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
|
|
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
|
|
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
|
|
|
|
// Load monster skills
|
|
await loadMonsterSkills(monster.id);
|
|
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
}
|
|
|
|
function cloneMonster(id) {
|
|
const monster = monsters.find(m => m.id === id);
|
|
if (!monster) return;
|
|
|
|
// Generate unique key suffix
|
|
let suffix = '_copy';
|
|
let newKey = monster.key + suffix;
|
|
let counter = 1;
|
|
while (monsters.some(m => m.key === newKey)) {
|
|
newKey = monster.key + suffix + counter;
|
|
counter++;
|
|
}
|
|
|
|
document.getElementById('monsterModalTitle').textContent = 'Clone Monster';
|
|
document.getElementById('monsterId').value = ''; // Empty = create new
|
|
document.getElementById('monsterName').value = monster.name + ' (Copy)';
|
|
document.getElementById('monsterKey').value = newKey;
|
|
document.getElementById('monsterMinLevel').value = monster.min_level;
|
|
document.getElementById('monsterMaxLevel').value = monster.max_level;
|
|
document.getElementById('monsterHp').value = monster.base_hp;
|
|
document.getElementById('monsterAtk').value = monster.base_atk;
|
|
document.getElementById('monsterDef').value = monster.base_def;
|
|
document.getElementById('monsterXp').value = monster.base_xp;
|
|
document.getElementById('monsterWeight').value = monster.spawn_weight || 100;
|
|
document.getElementById('monsterEnabled').checked = false; // Disabled by default
|
|
|
|
// Parse and copy dialogues
|
|
const dialogues = monster.dialogues ? JSON.parse(monster.dialogues) : {};
|
|
document.getElementById('dialogueAnnoyed').value = (dialogues.annoyed || []).join('\n');
|
|
document.getElementById('dialogueFrustrated').value = (dialogues.frustrated || []).join('\n');
|
|
document.getElementById('dialogueDesperate').value = (dialogues.desperate || []).join('\n');
|
|
document.getElementById('dialoguePhilosophical').value = (dialogues.philosophical || []).join('\n');
|
|
document.getElementById('dialogueExistential').value = (dialogues.existential || []).join('\n');
|
|
|
|
// Clear skills (cloned monster needs to be saved first)
|
|
currentMonsterSkills = [];
|
|
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
|
|
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('addMonsterBtn').addEventListener('click', () => {
|
|
document.getElementById('monsterModalTitle').textContent = 'Add Monster';
|
|
document.getElementById('monsterForm').reset();
|
|
document.getElementById('monsterId').value = '';
|
|
document.getElementById('monsterEnabled').checked = true;
|
|
// Clear skills (new monster needs to be saved first)
|
|
currentMonsterSkills = [];
|
|
document.getElementById('monsterSkillsList').innerHTML = '<p style="color: #666; font-size: 12px;">Save monster first, then edit to add skills.</p>';
|
|
document.getElementById('monsterModal').classList.add('active');
|
|
});
|
|
|
|
function closeMonsterModal() {
|
|
document.getElementById('monsterModal').classList.remove('active');
|
|
}
|
|
|
|
document.getElementById('monsterForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const id = document.getElementById('monsterId').value;
|
|
const dialogues = {
|
|
annoyed: document.getElementById('dialogueAnnoyed').value.split('\n').filter(l => l.trim()),
|
|
frustrated: document.getElementById('dialogueFrustrated').value.split('\n').filter(l => l.trim()),
|
|
desperate: document.getElementById('dialogueDesperate').value.split('\n').filter(l => l.trim()),
|
|
philosophical: document.getElementById('dialoguePhilosophical').value.split('\n').filter(l => l.trim()),
|
|
existential: document.getElementById('dialogueExistential').value.split('\n').filter(l => l.trim())
|
|
};
|
|
|
|
const data = {
|
|
name: document.getElementById('monsterName').value,
|
|
key: document.getElementById('monsterKey').value,
|
|
min_level: parseInt(document.getElementById('monsterMinLevel').value),
|
|
max_level: parseInt(document.getElementById('monsterMaxLevel').value),
|
|
base_hp: parseInt(document.getElementById('monsterHp').value),
|
|
base_atk: parseInt(document.getElementById('monsterAtk').value),
|
|
base_def: parseInt(document.getElementById('monsterDef').value),
|
|
base_xp: parseInt(document.getElementById('monsterXp').value),
|
|
spawn_weight: parseInt(document.getElementById('monsterWeight').value),
|
|
enabled: document.getElementById('monsterEnabled').checked,
|
|
dialogues: JSON.stringify(dialogues)
|
|
};
|
|
|
|
try {
|
|
if (id) {
|
|
await api(`/api/admin/monster-types/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('Monster updated');
|
|
} else {
|
|
await api('/api/admin/monster-types', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('Monster created');
|
|
}
|
|
closeMonsterModal();
|
|
loadMonsters();
|
|
} 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();
|
|
populateSkillSelect(); // Also update the monster skill dropdown
|
|
} catch (e) {
|
|
showToast('Failed to load skills: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderSkillTable() {
|
|
const tbody = document.getElementById('skillTableBody');
|
|
if (allSkills.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="11">No skills found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = allSkills.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('');
|
|
}
|
|
|
|
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' });
|
|
showToast('Skill deleted');
|
|
loadSkillsAdmin();
|
|
} catch (e) {
|
|
showToast('Failed to delete skill: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function editSkill(id) {
|
|
const skill = allSkills.find(s => s.id === id);
|
|
if (!skill) return;
|
|
|
|
document.getElementById('skillModalTitle').textContent = 'Edit Skill';
|
|
document.getElementById('skillEditId').value = skill.id;
|
|
document.getElementById('skillId').value = skill.id;
|
|
document.getElementById('skillId').disabled = true; // Can't change ID on edit
|
|
document.getElementById('skillName').value = skill.name;
|
|
document.getElementById('skillDescription').value = skill.description || '';
|
|
document.getElementById('skillType').value = skill.type;
|
|
document.getElementById('skillTarget').value = skill.target;
|
|
document.getElementById('skillMpCost').value = skill.mp_cost;
|
|
document.getElementById('skillBasePower').value = skill.base_power;
|
|
document.getElementById('skillAccuracy').value = skill.accuracy;
|
|
document.getElementById('skillHitCount').value = skill.hit_count;
|
|
document.getElementById('skillPlayerUsable').checked = skill.player_usable;
|
|
document.getElementById('skillMonsterUsable').checked = skill.monster_usable;
|
|
document.getElementById('skillEnabled').checked = skill.enabled;
|
|
|
|
// Parse status effect
|
|
if (skill.status_effect) {
|
|
try {
|
|
const effect = JSON.parse(skill.status_effect);
|
|
document.getElementById('skillStatusType').value = effect.type || '';
|
|
document.getElementById('skillStatusDamage').value = effect.damage || 5;
|
|
document.getElementById('skillStatusDuration').value = effect.duration || 3;
|
|
} catch {
|
|
document.getElementById('skillStatusType').value = '';
|
|
}
|
|
} else {
|
|
document.getElementById('skillStatusType').value = '';
|
|
document.getElementById('skillStatusDamage').value = 5;
|
|
document.getElementById('skillStatusDuration').value = 3;
|
|
}
|
|
|
|
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('skillModal').classList.add('active');
|
|
});
|
|
|
|
function closeSkillModal() {
|
|
document.getElementById('skillModal').classList.remove('active');
|
|
document.getElementById('skillId').disabled = false;
|
|
}
|
|
|
|
document.getElementById('skillForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const editId = document.getElementById('skillEditId').value;
|
|
const skillId = document.getElementById('skillId').value;
|
|
|
|
// Build status effect JSON if type is selected
|
|
let statusEffect = null;
|
|
const statusType = document.getElementById('skillStatusType').value;
|
|
if (statusType) {
|
|
statusEffect = JSON.stringify({
|
|
type: statusType,
|
|
damage: parseInt(document.getElementById('skillStatusDamage').value) || 5,
|
|
duration: parseInt(document.getElementById('skillStatusDuration').value) || 3
|
|
});
|
|
}
|
|
|
|
const data = {
|
|
id: skillId,
|
|
name: document.getElementById('skillName').value,
|
|
description: document.getElementById('skillDescription').value,
|
|
type: document.getElementById('skillType').value,
|
|
target: document.getElementById('skillTarget').value,
|
|
mp_cost: parseInt(document.getElementById('skillMpCost').value) || 0,
|
|
base_power: parseInt(document.getElementById('skillBasePower').value) || 100,
|
|
accuracy: parseInt(document.getElementById('skillAccuracy').value) || 95,
|
|
hit_count: parseInt(document.getElementById('skillHitCount').value) || 1,
|
|
status_effect: statusEffect,
|
|
player_usable: document.getElementById('skillPlayerUsable').checked,
|
|
monster_usable: document.getElementById('skillMonsterUsable').checked,
|
|
enabled: document.getElementById('skillEnabled').checked
|
|
};
|
|
|
|
try {
|
|
if (editId) {
|
|
await api(`/api/admin/skills/${editId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('Skill updated');
|
|
} else {
|
|
await api('/api/admin/skills', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
showToast('Skill created');
|
|
}
|
|
closeSkillModal();
|
|
loadSkillsAdmin();
|
|
} catch (e) {
|
|
showToast('Failed to save skill: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= USERS =============
|
|
async function loadUsers() {
|
|
try {
|
|
const data = await api('/api/admin/users');
|
|
users = data.users || [];
|
|
renderUserTable();
|
|
} catch (e) {
|
|
showToast('Failed to load users: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderUserTable() {
|
|
const tbody = document.getElementById('userTableBody');
|
|
if (users.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7">No users found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = users.map(u => `
|
|
<tr>
|
|
<td><strong>${escapeHtml(u.username)}</strong></td>
|
|
<td>${u.character_name ? escapeHtml(u.character_name) : '<em>No character</em>'}</td>
|
|
<td>${u.race || '-'} / ${u.class || '-'}</td>
|
|
<td><span class="badge badge-level">Lv ${u.level || 1}</span></td>
|
|
<td>
|
|
<small>HP: ${u.hp || 0}/${u.max_hp || 0} | MP: ${u.mp || 0}/${u.max_mp || 0}</small><br>
|
|
<small>ATK: ${u.atk || 0} | DEF: ${u.def || 0} | XP: ${u.xp || 0}</small>
|
|
</td>
|
|
<td>
|
|
<span class="badge ${u.is_admin ? 'badge-admin' : 'badge-user'}">
|
|
${u.is_admin ? 'Admin' : 'User'}
|
|
</span>
|
|
</td>
|
|
<td class="actions">
|
|
<button class="btn btn-secondary btn-small" onclick="editUser(${u.id})">Edit</button>
|
|
<button class="btn btn-secondary btn-small" onclick="toggleAdmin(${u.id}, ${!u.is_admin})">
|
|
${u.is_admin ? 'Revoke Admin' : 'Grant Admin'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
async function toggleAdmin(id, isAdmin) {
|
|
const action = isAdmin ? 'grant admin to' : 'revoke admin from';
|
|
if (!confirm(`Are you sure you want to ${action} this user?`)) return;
|
|
|
|
try {
|
|
await api(`/api/admin/users/${id}/admin`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ is_admin: isAdmin })
|
|
});
|
|
showToast(isAdmin ? 'Admin granted' : 'Admin revoked');
|
|
loadUsers();
|
|
} 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');
|
|
}
|
|
}
|
|
|
|
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)
|
|
});
|
|
showToast('User updated');
|
|
closeUserModal();
|
|
loadUsers();
|
|
} 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;
|
|
} 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 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
|
|
};
|
|
|
|
try {
|
|
await api('/api/admin/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(newSettings)
|
|
});
|
|
showToast('Settings saved');
|
|
loadSettings();
|
|
} catch (e) {
|
|
showToast('Failed to save settings: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
// ============= UTILITIES =============
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Initialize
|
|
checkAuth();
|
|
</script>
|
|
</body>
|
|
</html>
|