From 5a6986a8c539364b7a166abdfb608b3cc00ecb3b Mon Sep 17 00:00:00 2001
From: historia <[not public]>
Date: Mon, 13 Jul 2026 23:01:45 -0400
Subject: feat(admin): convert players page into characters page with basic
stats/yaml
---
internal/admin/static/admin.css | 2 +
internal/admin/static/charactereditor.js | 323 +++++++++++++++++++++++++++++++
2 files changed, 325 insertions(+)
create mode 100644 internal/admin/static/charactereditor.js
(limited to 'internal/admin/static')
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index f6072ad..63a12e5 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -172,6 +172,8 @@ details.dir-group summary.dir-header::-webkit-details-marker{display:none}
.dir-group[open]>.dir-header .dir-arrow::before{content:'\25BC'}
.dir-drop-target{background:rgba(15,52,96,.3)!important;outline:1px dashed var(--accent);outline-offset:-2px}
.editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)}
+.entity-link{color:var(--accent);text-decoration:none;cursor:pointer}
+.entity-link:hover{text-decoration:underline;color:#4a90d9}
.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 10px}
.form-grid .form-group{margin-bottom:4px}
.form-grid .form-group input,.form-grid .form-group select{font-size:11px;padding:4px 6px}
diff --git a/internal/admin/static/charactereditor.js b/internal/admin/static/charactereditor.js
new file mode 100644
index 0000000..bf40f09
--- /dev/null
+++ b/internal/admin/static/charactereditor.js
@@ -0,0 +1,323 @@
+function esc(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
+function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); }
+
+var charList = [];
+var currentView = null;
+var currentChar = null;
+var currentAcc = null;
+
+function initCharacterEditor() {
+ loadCharList();
+ if (window.location.hash) {
+ var hash = window.location.hash.substring(1);
+ if (hash.indexOf('account:') === 0) {
+ loadAccount(hash.substring(8));
+ } else {
+ loadCharacter(hash);
+ }
+ }
+}
+
+function loadCharList() {
+ API.get('/api/players').then(function(data) {
+ charList = data || [];
+ renderCharList('');
+ }).catch(function(e) {
+ notify('Failed to load characters: ' + e.message, 'error');
+ });
+}
+
+function filterCharList(val) {
+ renderCharList(val);
+}
+
+function renderCharList(filter) {
+ var el = $('#listEntries');
+ var f = (filter || '').toLowerCase();
+ var html = '';
+
+ var filtered = charList.filter(function(c) {
+ var name = (c.name || '').toLowerCase();
+ var account = (c.account || '').toLowerCase();
+ return !f || name.indexOf(f) >= 0 || account.indexOf(f) >= 0;
+ });
+
+ if (filtered.length === 0) {
+ html += '
';
+
+ html += sectionHeader('Identity');
+ html += '
';
+ html += fieldDisplay('Account', buildAccountLink(data.account || '-'));
+ html += fieldDisplay('Current Room', esc(data.room_id));
+ html += fieldDisplay('HP / Max HP', esc(data.hp) + ' / ' + esc(data.max_hp));
+ html += fieldDisplay('Credits', esc(data.credits));
+ html += fieldDisplay('Banked Credits', esc(data.banked_credits));
+ html += fieldDisplay('Combat Level', esc(data.combat_level));
+ html += fieldDisplay('Total Level', esc(data.total_level));
+ html += fieldDisplay('Attack Style', esc(data.attack_style));
+ html += fieldDisplay('Battery', esc(data.battery));
+ html += fieldDisplay('Prompt', esc(data.prompt));
+ html += '
';
+
+ if (data.description) {
+ html += sectionHeader('Description');
+ html += '
' + esc(data.description) + '
';
+ }
+
+ if (data.skill_levels && Object.keys(data.skill_levels).length > 0) {
+ html += sectionHeader('Skills');
+ html += '
';
+ var skillNames = Object.keys(data.skill_levels).sort();
+ skillNames.forEach(function(sk) {
+ html += fieldDisplay(capitalize(sk), esc(data.skill_levels[sk]));
+ });
+ html += '
';
+ }
+
+ if (data.equipment && Object.keys(data.equipment).length > 0) {
+ html += sectionHeader('Equipment');
+ html += '
';
+ var eqSlots = Object.keys(data.equipment).sort();
+ eqSlots.forEach(function(slot) {
+ html += fieldDisplay(formatSlot(slot), esc(data.equipment[slot]));
+ });
+ html += '
';
+ }
+
+ if (data.inventory && Object.keys(data.inventory).length > 0) {
+ html += sectionHeader('Inventory');
+ html += '
';
+ var invSlots = Object.keys(data.inventory).sort(byNum);
+ invSlots.forEach(function(sk) {
+ var slot = data.inventory[sk];
+ var item = slot ? (slot.item_id || JSON.stringify(slot)) : '-';
+ var qty = slot && slot.quantity ? ' x' + slot.quantity : '';
+ html += fieldDisplay('Slot ' + esc(sk), esc(item) + qty);
+ });
+ html += '
';
+ }
+
+ if (data.bank && Object.keys(data.bank).length > 0) {
+ html += sectionHeader('Bank');
+ html += '
';
+ var bankSlots = Object.keys(data.bank).sort(byNum);
+ bankSlots.forEach(function(sk) {
+ var slot = data.bank[sk];
+ var item = slot ? (slot.item_id || JSON.stringify(slot)) : '-';
+ var qty = slot && slot.quantity ? ' x' + slot.quantity : '';
+ html += fieldDisplay('Slot ' + esc(sk), esc(item) + qty);
+ });
+ html += '
';
+ }
+
+ if (data.stats) {
+ html += sectionHeader('Stats');
+ html += '
';
+ html += fieldDisplay('Total Kills', esc(data.stats.total_kills));
+ html += fieldDisplay('Deaths', esc(data.stats.deaths));
+ if (data.stats.rooms_visited) {
+ html += fieldDisplay('Unique Rooms', esc(roomVisitedCount(data.stats.rooms_visited)));
+ }
+ html += '
';
+
+ if (data.stats.mob_kills && Object.keys(data.stats.mob_kills).length > 0) {
+ html += sectionHeader('Mob Kills');
+ html += '
';
+ var mobs = Object.keys(data.stats.mob_kills).sort();
+ mobs.forEach(function(mob) {
+ html += fieldDisplay(capitalize(mob), esc(data.stats.mob_kills[mob]));
+ });
+ html += '
';
+ }
+ }
+
+ if (data.flags && Object.keys(data.flags).length > 0) {
+ html += sectionHeader('Flags');
+ html += '
';
+ var flagKeys = Object.keys(data.flags).sort();
+ flagKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.flags[k]));
+ });
+ html += '
';
+ }
+
+ html += '
';
+
+ html += '';
+ html += '
' + esc(data._raw || JSON.stringify(data, null, 2)) + '
';
+ html += '
';
+
+ $('#editorMain').innerHTML = html;
+}
+
+function loadAccount(name) {
+ currentAcc = name;
+ currentView = 'account';
+ currentChar = null;
+ renderCharList('');
+ window.location.hash = 'account:' + name;
+
+ API.get('/api/players/account/' + encodeURIComponent(name)).then(function(data) {
+ renderAccount(name, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '';
+
+ html += sectionHeader('Identity');
+ html += '
';
+ html += fieldDisplay('Name', esc(data.name));
+ html += fieldDisplay('Admin', data.admin ? 'Yes' : 'No');
+ html += '
';
+
+ if (data.characters && data.characters.length > 0) {
+ html += sectionHeader('Characters');
+ html += '
';
+ data.characters.forEach(function(charName) {
+ html += fieldDisplay('', buildCharacterLink(charName));
+ });
+ html += '
';
+ }
+
+ if (data.aliases && Object.keys(data.aliases).length > 0) {
+ html += sectionHeader('Aliases');
+ html += '
';
+ var aliasKeys = Object.keys(data.aliases).sort();
+ aliasKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.aliases[k]));
+ });
+ html += '
';
+ }
+
+ if (data.colors && Object.keys(data.colors).length > 0) {
+ html += sectionHeader('Colors');
+ html += '
';
+ var colorKeys = Object.keys(data.colors).sort();
+ colorKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.colors[k]));
+ });
+ html += '
';
+ }
+
+ if (data.options && Object.keys(data.options).length > 0) {
+ html += sectionHeader('Options');
+ html += '
';
+ var optKeys = Object.keys(data.options).sort();
+ optKeys.forEach(function(k) {
+ html += fieldDisplay(esc(k), esc(data.options[k]));
+ });
+ html += '
';
+ }
+
+ html += '
';
+
+ html += '';
+ html += '
' + esc(data._raw || JSON.stringify(data, null, 2)) + '
';
+ html += '
';
+
+ $('#editorMain').innerHTML = html;
+}
+
+function switchTab(e, tab) {
+ e.target.parentElement.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
+ e.target.classList.add('active');
+ var tabs = $('#editorMain').querySelectorAll('.tab-content');
+ tabs.forEach(function(t) { t.style.display = 'none'; });
+ var target = document.getElementById('tab' + tab.charAt(0).toUpperCase() + tab.slice(1));
+ if (target) target.style.display = 'block';
+}
+
+function buildAccountLink(accountName) {
+ if (!accountName || accountName === '-') return esc(accountName);
+ return '