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.
 
 
 
 
 

1237 lines
43 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-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;
}
/* Stats Display */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin: 15px 0;
}
.stat-item {
background: rgba(255,255,255,0.05);
padding: 10px;
border-radius: 6px;
text-align: center;
}
.stat-item .value {
font-size: 1.2rem;
font-weight: bold;
color: #4CAF50;
}
.stat-item .label {
font-size: 0.75rem;
color: #aaa;
margin-top: 4px;
}
/* Login Screen */
.login-screen {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #0f0f23;
}
.login-box {
background: #1a1a2e;
padding: 40px;
border-radius: 12px;
text-align: center;
border: 1px solid rgba(255,255,255,0.1);
}
.login-box h2 {
margin-bottom: 20px;
color: #4CAF50;
}
.login-box p {
color: #aaa;
margin-bottom: 20px;
}
</style>
</head>
<body>
<!-- Login Screen (shown if not authenticated) -->
<div id="loginScreen" class="login-screen" style="display: none;">
<div class="login-box">
<h2>HikeMap Admin</h2>
<p>You must be logged in as an admin to access this page.</p>
<a href="/" class="btn btn-primary">Go to Login</a>
</div>
</div>
<!-- Admin Container -->
<div id="adminContainer" class="admin-container" style="display: none;">
<!-- Sidebar -->
<nav class="admin-sidebar">
<div class="admin-logo">HikeMap Admin</div>
<a class="nav-item active" data-section="monsters">
<span class="icon">&#128126;</span> Monsters
</a>
<a class="nav-item" data-section="users">
<span class="icon">&#128100;</span> Users
</a>
<a class="nav-item" data-section="settings">
<span class="icon">&#9881;</span> Settings
</a>
<div class="nav-spacer"></div>
<a class="nav-item" href="/">
<span class="icon">&#8592;</span> Back to App
</a>
</nav>
<!-- Main Content -->
<main class="admin-main">
<!-- Monsters Section -->
<section id="monsters-section" class="section active">
<div class="section-header">
<h2>Monster Types</h2>
<button class="btn btn-primary" id="addMonsterBtn">+ Add Monster</button>
</div>
<table class="data-table" id="monsterTable">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Level</th>
<th>HP</th>
<th>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>
<!-- 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 (ms)</label>
<input type="number" id="setting-monsterSpawnInterval" placeholder="30000">
</div>
<div class="form-group">
<label>Max Monsters Per Player</label>
<input type="number" id="setting-maxMonstersPerPlayer" placeholder="10">
</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()">&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 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="dialogue-section">
<h4>Dialogues (one per line)</h4>
<div class="form-group">
<label>Annoyed (0-5 min)</label>
<textarea id="dialogueAnnoyed" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Frustrated (5-10 min)</label>
<textarea id="dialogueFrustrated" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Desperate (10-30 min)</label>
<textarea id="dialogueDesperate" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Philosophical (30-60 min)</label>
<textarea id="dialoguePhilosophical" placeholder="One dialogue line per line..."></textarea>
</div>
<div class="form-group">
<label>Existential (60+ min)</label>
<textarea id="dialogueExistential" placeholder="One dialogue line per line..."></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeMonsterModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Monster</button>
</div>
</form>
</div>
</div>
<!-- User Edit Modal -->
<div class="modal-overlay" id="userModal">
<div class="modal">
<div class="modal-header">
<h3>Edit User</h3>
<button class="modal-close" onclick="closeUserModal()">&times;</button>
</div>
<form id="userForm">
<input type="hidden" id="userId">
<div class="form-group">
<label>Username</label>
<input type="text" id="userUsername" disabled>
</div>
<div class="form-row">
<div class="form-group">
<label>Character Name</label>
<input type="text" id="userCharacterName">
</div>
<div class="form-group">
<label>Level</label>
<input type="number" id="userLevel" min="1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>XP</label>
<input type="number" id="userXp" min="0">
</div>
<div class="form-group">
<label>HP / Max HP</label>
<div class="form-row">
<input type="number" id="userHp" min="0">
<input type="number" id="userMaxHp" min="1">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>MP / Max MP</label>
<div class="form-row">
<input type="number" id="userMp" min="0">
<input type="number" id="userMaxMp" min="0">
</div>
</div>
<div class="form-group">
<label>ATK / DEF</label>
<div class="form-row">
<input type="number" id="userAtk" min="1">
<input type="number" id="userDef" min="0">
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" onclick="resetUserProgress()">Reset Progress</button>
<button type="button" class="btn btn-secondary" onclick="closeUserModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
// State
let accessToken = localStorage.getItem('accessToken');
let monsters = [];
let users = [];
let settings = {};
// 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();
}
// ============= 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');
}
}
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}`, {
method: 'PUT',
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');
}
}
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');
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');
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;
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');
}
});
// ============= 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
document.getElementById('setting-monsterSpawnInterval').value = settings.monsterSpawnInterval || 30000;
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 () => {
const newSettings = {
monsterSpawnInterval: document.getElementById('setting-monsterSpawnInterval').value,
maxMonstersPerPlayer: document.getElementById('setting-maxMonstersPerPlayer').value,
xpMultiplier: document.getElementById('setting-xpMultiplier').value,
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Initialize
checkAuth();
</script>
</body>
</html>