@ -662,6 +662,9 @@
< a class = "nav-item" data-section = "skills" >
< span class = "icon" > ⚡ < / span > Skills
< / a >
< a class = "nav-item" data-section = "classes" >
< span class = "icon" > 🏆 < / span > Classes
< / a >
< a class = "nav-item" data-section = "users" >
< span class = "icon" > 👤 < / span > Users
< / a >
@ -730,6 +733,30 @@
< / table >
< / 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" >
@ -1087,6 +1114,111 @@
< / 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()" > × < / 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');
@ -1095,6 +1227,8 @@
let settings = {};
let allSkills = [];
let currentMonsterSkills = []; // Skills for the monster being edited
let allClasses = [];
let currentClassSkills = []; // Skills for the class being edited
// API Helper
async function api(endpoint, options = {}) {
@ -1183,6 +1317,7 @@
loadUsers();
loadSettings();
loadSkillsAdmin(); // Load skills for admin (includes disabled)
loadClasses();
}
// ============= MONSTERS =============
@ -1728,6 +1863,335 @@
}
});
// ============= 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' });
showToast('Class deleted');
loadClasses();
} 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)
});
showToast('Class updated');
} else {
await api('/api/admin/classes', {
method: 'POST',
body: JSON.stringify(data)
});
showToast('Class created');
}
closeClassModal();
loadClasses();
} catch (e) {
showToast('Failed to save class: ' + e.message, 'error');
}
});
// ============= CLASS SKILLS =============
function populateClassSkillSelect() {
const select = document.getElementById('addClassSkillSelect');
select.innerHTML = '< option value = "" > -- Select a skill --< / option > ';
// Filter to player-usable skills
allSkills.filter(s => s.player_usable).forEach(skill => {
const opt = document.createElement('option');
opt.value = skill.id;
opt.textContent = `${skill.name} (${skill.type})`;
select.appendChild(opt);
});
}
async function loadClassSkills(classId) {
if (!classId) {
currentClassSkills = [];
renderClassSkills();
return;
}
try {
currentClassSkills = await api(`/api/admin/class-skills/${classId}`);
renderClassSkills();
} catch (e) {
console.error('Failed to load class skills:', e);
currentClassSkills = [];
renderClassSkills();
}
}
function renderClassSkills() {
const container = document.getElementById('classSkillsList');
if (currentClassSkills.length === 0) {
container.innerHTML = '< p style = "color: #666; font-size: 12px;" > No skills assigned. Add skills below.< / p > ';
return;
}
// Group by unlock level
const byLevel = {};
currentClassSkills.forEach(cs => {
const lvl = cs.unlock_level || 1;
if (!byLevel[lvl]) byLevel[lvl] = [];
byLevel[lvl].push(cs);
});
let html = '';
Object.keys(byLevel).sort((a, b) => a - b).forEach(level => {
const skills = byLevel[level];
html += `< div style = "margin-bottom: 10px; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 6px;" > `;
html += `< div style = "font-size: 11px; color: #4CAF50; margin-bottom: 5px;" > Level ${level}< / div > `;
skills.forEach(cs => {
const displayName = cs.custom_name || cs.base_name || cs.skill_id;
const choiceLabel = cs.choice_group ? `< span style = "color: #FF9800; font-size: 10px;" > Choice ${cs.choice_group}< / span > ` : '< span style = "color: #4CAF50; font-size: 10px;" > Auto< / span > ';
html += `
< div class = "monster-skill-item" data-id = "${cs.id}" >
< div class = "skill-name-section" >
< input type = "text" class = "skill-custom-name" value = "${escapeHtml(cs.custom_name || '')}"
placeholder="${escapeHtml(cs.base_name || cs.skill_id)}"
onchange="updateClassSkill(${cs.id}, 'custom_name', this.value)"
title="Custom name for this class">
< span class = "skill-base-name" > ${escapeHtml(cs.base_name || cs.skill_id)} ${choiceLabel}< / span >
< / div >
< div >
< label > Lvl< / label >
< input type = "number" class = "skill-min-level" value = "${cs.unlock_level}" min = "1"
onchange="updateClassSkill(${cs.id}, 'unlock_level', this.value)">
< / div >
< div >
< label > Grp< / label >
< input type = "number" class = "skill-weight" value = "${cs.choice_group || ''}" min = "1"
onchange="updateClassSkill(${cs.id}, 'choice_group', this.value || null)"
placeholder="-" title="Choice group (empty = auto-learn)">
< / div >
< button type = "button" class = "btn btn-danger btn-small" onclick = "removeClassSkill(${cs.id})" > ✕< / button >
< / div >
`;
});
html += '< / div > ';
});
container.innerHTML = html;
}
async function addClassSkill() {
const classId = document.getElementById('classEditId').value;
const skillId = document.getElementById('addClassSkillSelect').value;
const unlockLevel = parseInt(document.getElementById('addClassSkillLevel').value) || 1;
const choiceGroupVal = document.getElementById('addClassSkillChoiceGroup').value;
const choiceGroup = choiceGroupVal ? parseInt(choiceGroupVal) : null;
const customName = document.getElementById('addClassSkillName').value.trim();
if (!skillId) {
showToast('Please select a skill', 'error');
return;
}
if (!classId) {
showToast('Please save the class first before adding skills', 'error');
return;
}
// Check if skill already assigned
if (currentClassSkills.some(cs => cs.skill_id === skillId)) {
showToast('This skill is already assigned to this class', 'error');
return;
}
try {
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
})
});
showToast('Skill added');
await loadClassSkills(classId);
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)
});
// Reload to update display
const classId = document.getElementById('classEditId').value;
await loadClassSkills(classId);
} catch (e) {
showToast('Failed to update skill: ' + e.message, 'error');
}
}
async function removeClassSkill(id) {
try {
await api(`/api/admin/class-skills/${id}`, { method: 'DELETE' });
showToast('Skill removed');
const classId = document.getElementById('classEditId').value;
await loadClassSkills(classId);
} catch (e) {
showToast('Failed to remove skill: ' + e.message, 'error');
}
}
// ============= USERS =============
async function loadUsers() {
try {