aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/static')
-rw-r--r--internal/admin/static/admin.css102
-rw-r--r--internal/admin/static/admin.js55
-rw-r--r--internal/admin/static/colorpicker.js197
-rw-r--r--internal/admin/static/editor.js174
-rw-r--r--internal/admin/static/map.js775
-rw-r--r--internal/admin/static/talktree.js92
6 files changed, 1395 insertions, 0 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
new file mode 100644
index 0000000..c9d0ed5
--- /dev/null
+++ b/internal/admin/static/admin.css
@@ -0,0 +1,102 @@
+:root{--bg:#1a1a2e;--panel:#16213e;--text:#e0e0e0;--accent:#0f3460;--border:#2a2a4a;--hover:#1a3a6e;--danger:#c0392b;--success:#27ae60;--warn:#f39c12;--input-bg:#0d1b36;--input-border:#3a3a6a}
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:monospace;background:var(--bg);color:var(--text);min-height:100vh}
+.nav{background:var(--panel);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.nav a{color:var(--text);text-decoration:none;padding:4px 10px;border-radius:3px;font-size:13px}
+.nav a:hover,.nav a.active{background:var(--accent)}
+.nav .undo-bar{display:flex;gap:6px;margin-left:auto;align-items:center;font-size:12px}
+.nav .undo-bar button{padding:3px 8px;font-size:12px;border:1px solid var(--border);border-radius:3px;background:var(--input-bg);color:var(--text);cursor:pointer}
+.nav .undo-bar button:disabled{opacity:.4;cursor:default}
+.nav .undo-bar button:not(:disabled):hover{background:var(--accent)}
+.nav .undo-bar span{color:#888}
+.layout{display:flex;height:calc(100vh - 38px)}
+.main{flex:1;overflow:auto;position:relative}
+.panel{width:380px;background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:16px;flex-shrink:0}
+.panel h2{font-size:15px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
+.form-group{margin-bottom:10px}
+.form-group label{display:block;font-size:11px;color:#aaa;margin-bottom:3px;text-transform:uppercase;letter-spacing:.5px}
+.form-group input,.form-group textarea,.form-group select{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.form-group textarea{resize:vertical;min-height:60px}
+.form-group input[type=checkbox]{width:auto;margin-right:6px}
+.btn{padding:6px 14px;border:none;border-radius:3px;cursor:pointer;font-size:12px;font-weight:bold;font-family:monospace}
+.btn-primary{background:var(--accent);color:var(--text);border:1px solid #1a5a8e}
+.btn-primary:hover{background:var(--hover)}
+.btn-danger{background:var(--danger);color:#fff}
+.btn-danger:hover{opacity:.9}
+.btn-success{background:var(--success);color:#fff}
+.btn-sm{padding:3px 8px;font-size:11px}
+.btn-row{display:flex;gap:6px;margin-top:12px}
+.table{width:100%;border-collapse:collapse;font-size:12px}
+.table th,.table td{padding:4px 8px;text-align:left;border-bottom:1px solid var(--border)}
+.table th{color:#aaa;font-weight:bold;font-size:10px;text-transform:uppercase}
+.table tr:hover{background:rgba(15,52,96,.3)}
+.table a{color:var(--text);text-decoration:none}
+.table a:hover{color:#fff}
+.search-bar{margin-bottom:12px}
+.search-bar input{width:100%;padding:6px 8px;font-size:13px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px}
+.login{max-width:360px;margin:80px auto;background:var(--panel);padding:30px;border:1px solid var(--border);border-radius:6px}
+.login h1{font-size:18px;margin-bottom:20px;text-align:center}
+.z-controls{position:absolute;top:12px;left:12px;display:flex;gap:6px;align-items:center;background:var(--panel);padding:6px 10px;border-radius:4px;border:1px solid var(--border);z-index:10}
+.z-controls button{width:28px;height:28px;font-size:16px;line-height:1;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
+.z-controls button:hover{background:var(--accent)}
+.z-controls .z-label{font-size:13px;min-width:40px;text-align:center;font-weight:bold}
+.room-tooltip{position:absolute;background:var(--panel);border:1px solid var(--border);padding:6px 10px;border-radius:3px;font-size:11px;pointer-events:none;z-index:20;white-space:nowrap}
+.notification{position:fixed;bottom:16px;right:16px;background:var(--panel);border:1px solid var(--border);padding:10px 16px;border-radius:4px;font-size:12px;z-index:100;animation:fadeIn .2s}
+.notification.error{border-color:var(--danger)}
+.notification.success{border-color:var(--success)}
+@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
+.talk-tree .node{margin-left:16px;margin-bottom:6px;border-left:2px solid var(--accent);padding-left:10px}
+.talk-tree .node-header{display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:12px}
+.talk-tree .node-header .key{color:#6cf;font-weight:bold}
+.talk-tree .node-header .preview{color:#aaa;font-size:11px}
+.talk-tree .node-children{display:none;padding:4px 0}
+.talk-tree .node.open>.node-children,.talk-tree .node.root>.node-children{display:block}
+.talk-tree .option{margin-left:10px;padding:3px 6px;font-size:11px;color:#8cf;border-left:1px solid #444}
+.talk-tree .add-btn{font-size:10px;padding:2px 6px;background:var(--accent);color:var(--text);border:none;border-radius:2px;cursor:pointer;margin:4px 0}
+.talk-tree .add-btn:hover{opacity:.8}
+.tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
+.tab{padding:6px 14px;font-size:12px;background:transparent;color:#aaa;border:none;border-bottom:2px solid transparent;cursor:pointer;font-family:monospace}
+.tab:hover{color:var(--text)}
+.tab.active{color:var(--text);border-bottom-color:var(--accent)}
+.kv-row{display:flex;gap:6px;align-items:center;margin-bottom:6px}
+.kv-row input{flex:1;min-width:0}
+.kv-row button{flex-shrink:0}
+.color-swatch{display:inline-block;width:16px;height:16px;border-radius:2px;border:1px solid #555;vertical-align:middle;margin-right:4px}
+.color-picker{display:flex;flex-wrap:wrap;gap:2px;margin-top:4px;max-height:200px;overflow-y:auto}
+.color-picker .swatch{width:18px;height:18px;border:1px solid transparent;cursor:pointer;border-radius:1px}
+.color-picker .swatch:hover,.color-picker .swatch.selected{border-color:#fff;transform:scale(1.2);z-index:1}
+.editor-container{display:flex;height:calc(100vh - 38px)}
+.editor-list{width:260px;background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;padding:10px;flex-shrink:0}
+.editor-main{flex:1;overflow-y:auto;padding:16px}
+.editor-list .item{padding:5px 8px;font-size:12px;cursor:pointer;border-radius:3px;margin-bottom:2px}
+.editor-list .item:hover{background:rgba(15,52,96,.3)}
+.editor-list .item.active{background:var(--accent)}
+.editor-list h3{font-size:13px;margin-bottom:8px;color:#aaa}
+.editor-main pre{background:var(--input-bg);padding:12px;border-radius:4px;font-size:12px;overflow-x:auto;border:1px solid var(--border)}
+.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}
+.form-grid .form-group label{font-size:9px;margin-bottom:1px}
+.form-grid .form-group textarea{font-size:11px;padding:4px 6px;min-height:40px}
+.form-grid .form-group.full{grid-column:1/-1}
+.form-grid .form-group.full textarea{min-height:60px}
+.cp-grid{display:grid;grid-template-columns:repeat(16,1fr);gap:2px}
+.cp-swatch{aspect-ratio:1;border:1px solid rgba(255,255,255,.15);cursor:pointer;border-radius:2px;min-width:14px}
+.cp-swatch:hover{transform:scale(1.3);z-index:1;border-color:#fff}
+.cp-swatch.selected{outline:2px solid #fff;outline-offset:1px;z-index:1}
+.color-field{display:flex;gap:6px;align-items:center}
+.color-field input{flex:1;min-width:0;font-family:monospace;text-transform:uppercase}
+.color-field .color-swatch{width:24px;height:24px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer}
+.ghost-hover{cursor:pointer;transition:opacity .1s}
+.ghost-hover:hover{opacity:1!important}
+rect.ghost:hover{fill:rgba(100,200,255,0.28)!important;stroke:rgba(100,200,255,0.55)!important}
+g.ud-hover{cursor:pointer}
+g.ud-hover:hover rect{opacity:0.35!important}
+g.ud-hover text{font-size:11px}
+g.ud-hover:hover text{font-weight:bold}
+.cm-overlay{position:fixed;inset:0;z-index:199}
+.cm-menu{position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:4px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.4)}
+.cm-menu button{display:block;width:100%;padding:6px 12px;text-align:left;font-size:12px;font-family:monospace;background:none;border:none;color:var(--text);cursor:pointer}
+.cm-menu button:hover{background:var(--accent)}
+.cm-menu button.danger{color:#f55}
+.cm-menu button.danger:hover{background:var(--danger)}
diff --git a/internal/admin/static/admin.js b/internal/admin/static/admin.js
new file mode 100644
index 0000000..cce3bc9
--- /dev/null
+++ b/internal/admin/static/admin.js
@@ -0,0 +1,55 @@
+window.API = {
+ async get(url) { const r = await fetch(url,{credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async post(url,data) { const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async put(url,data) { const r = await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); },
+ async del(url) { const r = await fetch(url,{method:'DELETE',credentials:'same-origin'}); if(!r.ok)throw new Error(await r.text()); return r.json(); }
+};
+
+window.$ = (sel) => document.querySelector(sel);
+window.$$ = (sel) => document.querySelectorAll(sel);
+
+window.notify = function(msg, type) {
+ var el = document.createElement('div');
+ el.className = 'notification ' + (type || '');
+ el.textContent = msg;
+ document.body.appendChild(el);
+ setTimeout(function() { el.remove(); }, 3000);
+};
+
+window.undoState = null;
+
+window.updateUndoBar = function() {
+ API.get('/api/undo/state').then(function(s) {
+ undoState = s;
+ var uBtn = $('.undo-btn');
+ var rBtn = $('.redo-btn');
+ var uLabel = $('.undo-label');
+ if (uBtn) { uBtn.disabled = !s.can_undo; uBtn.title = s.undo_desc || ''; }
+ if (rBtn) { rBtn.disabled = !s.can_redo; rBtn.title = s.redo_desc || ''; }
+ if (uLabel) { uLabel.textContent = s.undo_desc || ''; }
+ }).catch(function() {});
+};
+
+window.doUndo = function() {
+ API.post('/api/undo/undo').then(function(r) { notify(r.message || 'Undo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Undo failed: '+e.message, 'error'); });
+};
+
+window.doRedo = function() {
+ API.post('/api/undo/redo').then(function(r) { notify(r.message || 'Redo complete', 'success'); updateUndoBar(); if(window.refreshPage)refreshPage(); }).catch(function(e) { notify('Redo failed: '+e.message, 'error'); });
+};
+
+window.addEventListener('keydown', function(e) {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
+ e.preventDefault();
+ if (e.shiftKey) { doRedo(); } else { doUndo(); }
+ }
+});
+
+window.saveForm = function(url, data, msg) {
+ API.put(url, data).then(function() {
+ updateUndoBar();
+ notify(msg || 'Saved', 'success');
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
+};
+
+document.addEventListener('DOMContentLoaded', function() { updateUndoBar(); });
diff --git a/internal/admin/static/colorpicker.js b/internal/admin/static/colorpicker.js
new file mode 100644
index 0000000..9431e83
--- /dev/null
+++ b/internal/admin/static/colorpicker.js
@@ -0,0 +1,197 @@
+window.xtermToRGB = function(idx) {
+ idx = parseInt(idx);
+ if (idx < 0 || idx > 255) return [0,0,0];
+ if (idx < 16) {
+ var a = [[0,0,0],[170,0,0],[0,170,0],[170,170,0],[0,0,170],[170,0,170],[0,170,170],[170,170,170],
+ [85,85,85],[255,85,85],[85,255,85],[255,255,85],[85,85,255],[255,85,255],[85,255,255],[255,255,255]];
+ return a[idx];
+ }
+ if (idx < 232) {
+ var n = idx - 16;
+ var cube = [0,95,135,175,215,255];
+ return [cube[Math.floor(n/36)], cube[Math.floor((n%36)/6)], cube[n%6]];
+ }
+ var g = 8 + (idx-232)*10;
+ return [g,g,g];
+};
+
+window.xtermIdxToHex = function(idx) {
+ idx = parseInt(idx) || 0;
+ return idx.toString(16).toUpperCase().padStart(2,'0');
+};
+
+window.hexToXtermIdx = function(hex) {
+ return parseInt(hex, 16);
+};
+
+window.xtermToCss = function(idx) {
+ var rgb = xtermToRGB(idx);
+ return '#' + ((rgb[0]<<16)|(rgb[1]<<8)|rgb[2]).toString(16).padStart(6,'0').toUpperCase();
+};
+
+window.resolveColor = function(v) {
+ if (!v) return '#3a3a6a';
+ v = v.toUpperCase().replace(/[^0-9A-F]/g,'');
+ if (v.length === 2) return xtermToCss(parseInt(v,16));
+ return '#' + v.substring(0,6).padEnd(6,'0');
+};
+
+window.hexLuminance = function(hex) {
+ hex = hex.replace('#','').toUpperCase();
+ var r = parseInt(hex.substring(0,2),16)/255;
+ var g = parseInt(hex.substring(2,4),16)/255;
+ var b = parseInt(hex.substring(4,6),16)/255;
+ return 0.299*r + 0.587*g + 0.114*b;
+};
+
+window.cssToXtermIdx = function(css) {
+ css = css.replace('#','').toUpperCase();
+ if (css.length < 6) return 0;
+ var tr = parseInt(css.substring(0,2),16);
+ var tg = parseInt(css.substring(2,4),16);
+ var tb = parseInt(css.substring(4,6),16);
+ var best = 0, bestDist = Infinity;
+ for (var i = 0; i < 256; i++) {
+ var srgb = xtermToRGB(i);
+ var dr = tr-srgb[0], dg = tg-srgb[1], db = tb-srgb[2];
+ var d = dr*dr + dg*dg + db*db;
+ if (d < bestDist) { bestDist = d; best = i; }
+ }
+ return best;
+};
+
+(function() {
+ var pickerEl = null, boundInput = null, boundSwatch = null;
+
+ function ensurePicker() {
+ if (pickerEl) return;
+ pickerEl = document.createElement('div');
+ pickerEl.id = '__cp_popover';
+ pickerEl.style.cssText = 'display:none;position:fixed;z-index:200;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.5);min-width:280px';
+ pickerEl.innerHTML = buildPickerHTML();
+ document.body.appendChild(pickerEl);
+
+ pickerEl.addEventListener('click', function(e) {
+ var sw = e.target.closest('.cp-swatch');
+ if (sw) {
+ var idx = parseInt(sw.getAttribute('data-idx'));
+ var hex = xtermIdxToHex(idx);
+ if (boundInput) boundInput.value = hex;
+ if (boundSwatch) boundSwatch.style.background = xtermToCss(idx);
+ if (boundInput) boundInput.dispatchEvent(new Event('input', {bubbles:true}));
+ hidePicker();
+ }
+ });
+ }
+
+ function buildPickerHTML() {
+ var h = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">';
+ h += '<span id="__cp_preview" class="color-swatch" style="width:24px;height:24px;flex-shrink:0"></span>';
+ h += '<input id="__cp_hex" style="flex:1;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;text-transform:uppercase" maxlength="2" placeholder="00">';
+ h += '<button id="__cp_close" style="background:none;border:none;color:#aaa;cursor:pointer;font-size:14px;padding:0 4px">&times;</button>';
+ h += '</div>';
+ h += '<div class="cp-grid">';
+ for (var i = 0; i < 256; i++) {
+ var css = xtermToCss(i);
+ h += '<div class="cp-swatch" data-idx="'+i+'" style="background:'+css+'" title="'+xtermIdxToHex(i)+' ('+i+')"></div>';
+ }
+ h += '</div>';
+ return h;
+ }
+
+ window.showColorPicker = function(inputEl, swatchEl) {
+ ensurePicker();
+ boundInput = inputEl;
+ boundSwatch = swatchEl;
+
+ var raw = (inputEl.value || '').toUpperCase().replace(/[^0-9A-F]/g,'');
+ var idx;
+ if (raw.length === 2) {
+ idx = parseInt(raw, 16);
+ } else if (raw.length >= 6) {
+ idx = cssToXtermIdx(raw);
+ } else {
+ idx = parseInt(raw, 16) || 0;
+ }
+ var hex = xtermIdxToHex(idx);
+ inputEl.value = hex;
+ if (swatchEl) swatchEl.style.background = xtermToCss(idx);
+
+ $('#__cp_hex').value = hex;
+ $('#__cp_preview').style.background = xtermToCss(idx);
+ updatePickerSelection(idx);
+
+ var rect = inputEl.getBoundingClientRect();
+ pickerEl.style.display = 'block';
+ var pw = pickerEl.offsetWidth;
+ var left = rect.left;
+ if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10);
+ pickerEl.style.left = left + 'px';
+ pickerEl.style.top = (rect.bottom + 4) + 'px';
+
+ $('#__cp_hex').oninput = function() {
+ var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2);
+ this.value = v;
+ if (v.length === 2) {
+ var i = parseInt(v, 16);
+ inputEl.value = v;
+ if (swatchEl) swatchEl.style.background = xtermToCss(i);
+ updatePickerSelection(i);
+ }
+ };
+
+ $('#__cp_close').onclick = hidePicker;
+ };
+
+ function updatePickerSelection(idx) {
+ var swatches = document.querySelectorAll('.cp-swatch');
+ for (var i = 0; i < swatches.length; i++) swatches[i].classList.remove('selected');
+ var s = document.querySelector('.cp-swatch[data-idx="'+idx+'"]');
+ if (s) s.classList.add('selected');
+ }
+
+ window.hideColorPicker = hidePicker;
+ function hidePicker() {
+ if (pickerEl) pickerEl.style.display = 'none';
+ boundInput = null; boundSwatch = null;
+ }
+
+ document.addEventListener('click', function(e) {
+ if (!pickerEl || pickerEl.style.display === 'none') return;
+ if (!pickerEl.contains(e.target) && e.target !== boundInput && e.target !== (boundSwatch || {}).parentElement) {
+ hidePicker();
+ }
+ });
+
+ window.bindColorField = function(fieldEl) {
+ var input = fieldEl.querySelector('input[type=text]') || fieldEl.querySelector('input:not([type])');
+ var swatch = fieldEl.querySelector('.color-swatch');
+ if (!input) return;
+
+ fieldEl.addEventListener('click', function(e) {
+ if (e.target === input || e.target === swatch) {
+ showColorPicker(input, swatch);
+ }
+ });
+
+ var raw = (input.value || '').toUpperCase().replace(/[^0-9A-F]/g,'');
+ if (raw.length === 2) {
+ if (swatch) swatch.style.background = xtermToCss(parseInt(raw, 16));
+ } else if (raw.length >= 6) {
+ var idx = cssToXtermIdx(raw);
+ input.value = xtermIdxToHex(idx);
+ if (swatch) swatch.style.background = xtermToCss(idx);
+ }
+
+ input.addEventListener('input', function() {
+ var v = this.value.toUpperCase().replace(/[^0-9A-F]/g,'').substring(0,2);
+ this.value = v;
+ if (v.length === 2 && swatch) {
+ swatch.style.background = xtermToCss(parseInt(v, 16));
+ }
+ });
+ input.setAttribute('maxlength', '2');
+ input.setAttribute('placeholder', '00');
+ input.style.textTransform = 'uppercase';
+ };
+})();
diff --git a/internal/admin/static/editor.js b/internal/admin/static/editor.js
new file mode 100644
index 0000000..d9ec36f
--- /dev/null
+++ b/internal/admin/static/editor.js
@@ -0,0 +1,174 @@
+var editorType = '';
+var editorFields = [];
+var currentID = null;
+var allIDs = [];
+
+function initEditor(type, fields) {
+ editorType = type;
+ editorFields = fields;
+ loadList();
+ var hash = window.location.hash.substring(1);
+ if (hash) { loadItem(hash); }
+}
+
+function loadList() {
+ API.get('/api/' + editorType).then(function(data) {
+ allIDs = data.ids || data || [];
+ renderList('');
+ }).catch(function(e) {
+ notify('Failed to load list: ' + e.message, 'error');
+ });
+}
+
+function renderList(filter) {
+ var el = $('#listEntries');
+ var f = filter.toLowerCase();
+ var filtered = allIDs.filter(function(id) { return !f || String(id).toLowerCase().indexOf(f) >= 0; });
+ el.innerHTML = filtered.map(function(id) {
+ return '<div class="item' + (id === currentID ? ' active' : '') + '" onclick="loadItem(\'' + esc(id) + '\')">' + esc(id) + '</div>';
+ }).join('');
+}
+
+function filterList(val) {
+ renderList(val);
+}
+
+function loadItem(id) {
+ currentID = id;
+ renderList('');
+ window.location.hash = id;
+ API.get('/api/' + editorType + '/' + encodeURIComponent(id)).then(function(data) {
+ renderEditor(id, data);
+ }).catch(function(e) {
+ $('#editorMain').innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(e.message) + '</p>';
+ });
+}
+
+function renderEditor(id, data) {
+ var obj = data;
+ var html = '<h2>' + esc(editorType.charAt(0).toUpperCase() + editorType.slice(1)) + ': ' + esc(id) + '</h2>';
+
+ html += '<div class="tabs"><button class="tab active" onclick="switchTab(event,\'fields\')">Fields</button>';
+ html += '<button class="tab" onclick="switchTab(event,\'yaml\')">Raw YAML</button></div>';
+
+ html += '<div class="tab-content" id="tabFields">';
+ html += '<div class="form-grid">';
+ editorFields.forEach(function(f) {
+ var val = getNested(obj, f.key);
+ val = val === undefined || val === null ? '' : val;
+ var cls = f.type === 'textarea' ? 'form-group full' : 'form-group';
+ if (f.type === 'textarea') {
+ var displayVal;
+ if (f.encode) {
+ displayVal = f.encode(val);
+ } else if (typeof val === 'object') {
+ displayVal = JSON.stringify(val, null, 2);
+ } else {
+ displayVal = String(val);
+ }
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><textarea id="f_' + esc(f.key) + '" rows="4">' + esc(displayVal) + '</textarea></div>';
+ } else if (f.type === 'checkbox') {
+ html += '<div class="'+cls+'"><label><input type="checkbox" id="f_' + esc(f.key) + '"' + (val ? ' checked' : '') + '> ' + esc(f.label) + '</label></div>';
+ } else if (f.type === 'number') {
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input type="number" id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>';
+ } else if (f.type === 'color') {
+ html += '<div class="'+cls+' color-field"><label>' + esc(f.label) + '</label><span class="color-swatch" style="background:#' + escAttr(String(val) || '808080') + '"></span><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val) || '') + '"></div>';
+ } else {
+ html += '<div class="'+cls+'"><label>' + esc(f.label) + '</label><input id="f_' + esc(f.key) + '" value="' + escAttr(String(val)) + '"></div>';
+ }
+ });
+ html += '</div>';
+ html += '</div>';
+
+ html += '<div class="tab-content" id="tabYaml" style="display:none">';
+ html += '<pre>' + esc(obj._raw || JSON.stringify(obj, null, 2)) + '</pre>';
+ html += '</div>';
+
+ html += '<div class="btn-row">';
+ html += '<button class="btn btn-primary" onclick="saveItem(\'' + esc(id) + '\')">Save</button>';
+ html += '<button class="btn btn-danger" onclick="deleteItem(\'' + esc(id) + '\')">Delete</button>';
+ html += '</div>';
+
+ $('#editorMain').innerHTML = html;
+ setTimeout(function() {
+ document.querySelectorAll('.color-field').forEach(function(el) { bindColorField(el); });
+ }, 50);
+}
+
+function saveItem(id) {
+ var data = {};
+ editorFields.forEach(function(f) {
+ var el = $('#f_' + f.key);
+ if (!el) return;
+ var val;
+ if (f.type === 'checkbox') {
+ val = el.checked;
+ } else if (f.type === 'number') {
+ val = parseFloat(el.value) || 0;
+ } else if (f.type === 'textarea') {
+ val = el.value;
+ } else {
+ val = el.value;
+ }
+ if (f.parser) {
+ val = f.parser(val);
+ }
+ setNested(data, f.key, val);
+ });
+ data.id = id;
+
+ API.put('/api/' + editorType + '/' + encodeURIComponent(id), data).then(function() {
+ notify(editorType + ' ' + id + ' saved', 'success');
+ updateUndoBar();
+ }).catch(function(e) { notify('Save failed: ' + e.message, 'error'); });
+}
+
+function deleteItem(id) {
+ if (!confirm('Delete ' + editorType + ' "' + id + '"?')) return;
+ API.del('/api/' + editorType + '/' + encodeURIComponent(id)).then(function() {
+ notify(editorType + ' ' + id + ' deleted', 'success');
+ updateUndoBar();
+ currentID = null;
+ $('#editorMain').innerHTML = '<p style="color:#888;text-align:center;margin-top:60px">Deleted</p>';
+ loadList();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+function createNew() {
+ var name = prompt(editorType + ' ID:');
+ if (!name) return;
+ API.post('/api/' + editorType, {id: name}).then(function() {
+ notify(editorType + ' ' + name + ' created', 'success');
+ updateUndoBar();
+ loadList();
+ loadItem(name);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+}
+
+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 getNested(obj, path) {
+ return path.split('.').reduce(function(o, k) { return o && o[k] !== undefined ? o[k] : undefined; }, obj);
+}
+
+function setNested(obj, path, val) {
+ var keys = path.split('.');
+ var last = keys.pop();
+ var target = keys.reduce(function(o, k) {
+ if (!o[k]) o[k] = {};
+ return o[k];
+ }, obj);
+ target[last] = val;
+}
+
+function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
+function escAttr(s) { return String(s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;'); }
+
+window.refreshPage = function() { loadList(); if (currentID) loadItem(currentID); };
diff --git a/internal/admin/static/map.js b/internal/admin/static/map.js
new file mode 100644
index 0000000..3443339
--- /dev/null
+++ b/internal/admin/static/map.js
@@ -0,0 +1,775 @@
+var CELL = 100;
+var currentZ = 0, currentDir = '', selectedRoom = null, mapData = null;
+var panX = 0, panY = 0, scale = 1;
+var dragging = false, startX = 0, startY = 0, prevX = 0, prevY = 0;
+var dragRoom = false;
+var linkDrag = false, linkFromId = null, linkTargetId = null, linkGhostDir = null;
+var delDrag = false, delFromId = null, delTargetId = null;
+var roomMap = {}, occupied = {};
+var gridDirs = [
+ {dx:0,dy:-1,dir:'north'},{dx:0,dy:1,dir:'south'},
+ {dx:1,dy:0,dir:'east'},{dx:-1,dy:0,dir:'west'},
+ {dx:1,dy:-1,dir:'northeast'},{dx:-1,dy:-1,dir:'northwest'},
+ {dx:1,dy:1,dir:'southeast'},{dx:-1,dy:1,dir:'southwest'}
+];
+var saveTimer = null;
+var upTarget = {}, downTarget = {};
+
+function changeZ(dz) {
+ if (dz === 0) currentZ = 0; else currentZ += dz;
+ loadMap();
+}
+
+function changeDir(dir) {
+ currentDir = dir;
+ loadMap();
+}
+
+function loadMap() {
+ $('#zLabel').textContent = 'Z=' + currentZ;
+ var url = '/api/map?z=' + currentZ;
+ if (currentDir) url += '&dir=' + encodeURIComponent(currentDir);
+ API.get(url).then(function(data) {
+ mapData = data;
+ if (!currentDir && data.dir) {
+ currentDir = data.dir;
+ }
+ updateDirSelect();
+ renderMap(data);
+ }).catch(function(e) {
+ notify('Failed to load map: ' + e.message, 'error');
+ });
+}
+
+function renderMap(data) {
+ var svg = $('#mapSvg');
+ var container = $('#mapContainer');
+ var W = container.clientWidth;
+ var H = container.clientHeight;
+
+ roomMap = {};
+ if (data.rooms) data.rooms.forEach(function(r) { roomMap[r.id] = r; });
+
+ occupied = {};
+ if (data.rooms) data.rooms.forEach(function(r) { occupied[r.x+','+r.y] = r.id; });
+
+ upTarget = {}; downTarget = {};
+ if (data.upLinks) data.upLinks.forEach(function(l) { upTarget[l.from] = l.to; });
+ if (data.downLinks) data.downLinks.forEach(function(l) { downTarget[l.from] = l.to; });
+
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var g = '';
+
+ if (data.links) {
+ data.links.forEach(function(l) {
+ var fr = roomMap[l.from], tr = roomMap[l.to];
+ if (!fr || !tr) return;
+ var x1 = fr.x * CELL + CELL/2, y1 = fr.y * CELL + CELL/2;
+ var x2 = tr.x * CELL + CELL/2, y2 = tr.y * CELL + CELL/2;
+ var c = fr.color ? resolveColor(fr.color) : '#666';
+ c = c.replace('#','');
+ g += '<line x1="'+x1+'" y1="'+y1+'" x2="'+x2+'" y2="'+y2+'" stroke="#'+c+'" stroke-width="6" opacity="0.35"'+ (l.bidirectional ? '' : ' stroke-dasharray="4,3"')+' vector-effect="non-scaling-stroke"/>';
+ });
+ }
+
+ var hasUp = {}, hasDown = {};
+ if (data.upLinks) data.upLinks.forEach(function(l) { hasUp[l.from] = true; });
+ if (data.downLinks) data.downLinks.forEach(function(l) { hasDown[l.from] = true; });
+
+ var roomHTML = '';
+ if (data.rooms) {
+ data.rooms.forEach(function(r) {
+ var x = r.x * CELL + 8, y = r.y * CELL + 8, w = CELL-16, h = CELL-16;
+ var fill = resolveColor(r.color);
+ var txtColor = hexLuminance(fill) > 0.35 ? '#111' : '#fff';
+ var sel = selectedRoom === r.id;
+ roomHTML += '<rect class="rm" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="5" fill="'+fill+'" stroke="'+(sel?'#fff':fill)+'" stroke-width="'+(sel?3:1.5)+'" data-id="'+r.id+'" data-name="'+esc(r.name)+'"/>';
+ roomHTML += wrapText(r.name, x+w/2, y+h/2, w, txtColor);
+
+ var btnY = y+h-4;
+ var topY = y+4;
+ if (hasUp[r.id] && upTarget[r.id]) {
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+3)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="1" data-target="'+upTarget[r.id]+'" title="Go up to #'+upTarget[r.id]+'">\u2191</text></g>';
+ }
+ if (hasDown[r.id] && downTarget[r.id]) {
+ roomHTML += '<g class="ud-hover"><rect class="nav-btn-bg" x="'+(x+w-17)+'" y="'+(topY+2)+'" width="14" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="nav-btn" x="'+(x+w-10)+'" y="'+(topY+14)+'" text-anchor="middle" fill="'+txtColor+'" data-action="goto" data-dz="-1" data-target="'+downTarget[r.id]+'" title="Go down to #'+downTarget[r.id]+'">\u2193</text></g>';
+ }
+
+ if (sel) {
+ var btnX = x + 6, btnBW = 18;
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(btnX-2)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(btnX+btnBW/2-2)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="up" title="Create room up">+\u2191</text></g>';
+ roomHTML += '<g class="ud-hover"><rect class="new-btn-bg" x="'+(x+w-20)+'" y="'+(btnY-12)+'" width="'+btnBW+'" height="14" rx="3" fill="'+txtColor+'" opacity="0.12" pointer-events="none"/><text class="ud-btn" x="'+(x+w-11)+'" y="'+btnY+'" text-anchor="middle" font-size="10" fill="'+txtColor+'" data-dir="down" title="Create room down">+\u2193</text></g>';
+ }
+ });
+ }
+
+ var ghostHTML = '';
+ if (selectedRoom && roomMap[selectedRoom]) {
+ var sr = roomMap[selectedRoom];
+ gridDirs.forEach(function(d) {
+ var nx = sr.x + d.dx, ny = sr.y + d.dy;
+ var key = nx+','+ny;
+ if (!occupied[key]) {
+ ghostHTML += '<rect class="ghost ghost-hover" x="'+(nx*CELL+8)+'" y="'+(ny*CELL+8)+'" width="'+(CELL-16)+'" height="'+(CELL-16)+'" rx="5" fill="rgba(100,200,255,0.12)" stroke="rgba(100,200,255,0.25)" stroke-width="1.5" stroke-dasharray="4,4" data-dir="'+d.dir+'" data-gx="'+nx+'" data-gy="'+ny+'"/>';
+ ghostHTML += '<text x="'+(nx*CELL+CELL/2)+'" y="'+(ny*CELL+CELL/2+4)+'" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.35)" pointer-events="none" font-family="monospace">+</text>';
+ }
+ });
+ }
+
+ var innerHTML = g + roomHTML + ghostHTML;
+ var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
+ var svgHTML = '<svg width="'+W+'" height="'+H+'" viewBox="'+vb.join(' ')+'" xmlns="'+svgNS+'"><g>'+innerHTML+'</g></svg>';
+ svg.innerHTML = svgHTML;
+
+ svg.addEventListener('contextmenu', function(e) { e.preventDefault(); });
+
+ svg.onmousedown = function(e) {
+ var onRoom = e.target.classList.contains('rm');
+ var onGhost = e.target.classList.contains('ghost');
+ var onUdBtn = e.target.classList.contains('ud-btn');
+ var onNavBtn = e.target.classList.contains('nav-btn');
+
+ if (onNavBtn) {
+ e.stopPropagation();
+ var dz = parseInt(e.target.getAttribute('data-dz'));
+ var tid = parseInt(e.target.getAttribute('data-target'));
+ currentZ += dz;
+ selectedRoom = tid;
+ loadMap();
+ selectRoom(tid);
+ return;
+ }
+
+ if (e.button === 2 && onRoom) {
+ delFromId = parseInt(e.target.getAttribute('data-id'));
+ delDrag = false; delTargetId = null;
+ dragging = true;
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ dragRoom = true; linkFromId = null; linkDrag = false; linkTargetId = null; linkGhostDir = null;
+ clearLinkHighlight();
+ return;
+ }
+
+ if (e.button !== 0) return;
+
+ if (onUdBtn) {
+ e.stopPropagation();
+ var dir = e.target.getAttribute('data-dir');
+ createRoom(selectedRoom, dir);
+ return;
+ }
+
+ dragRoom = onRoom || onGhost;
+ dragging = true;
+ startX = e.clientX; startY = e.clientY;
+ prevX = e.clientX; prevY = e.clientY;
+ linkFromId = onRoom ? parseInt(e.target.getAttribute('data-id')) : null;
+ linkDrag = false;
+ linkTargetId = null;
+ linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ clearLinkHighlight();
+ };
+
+ svg.onmousemove = function(e) {
+ if (!dragging) return;
+ var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
+
+ if (delFromId && moved && !delDrag) {
+ delDrag = true;
+ }
+ if (delDrag && moved) {
+ updateDelDragTarget(e);
+ return;
+ }
+
+ if (!dragRoom && !delDrag && !delFromId) {
+ panX += e.clientX - prevX;
+ panY += e.clientY - prevY;
+ prevX = e.clientX; prevY = e.clientY;
+ updateView();
+ }
+
+ if (linkFromId && moved) {
+ linkDrag = true;
+ updateLinkDragTarget(e);
+ }
+ };
+
+ svg.onmouseup = function(e) {
+ if (!dragging) return;
+
+ var moved = Math.abs(e.clientX - startX) >= 4 || Math.abs(e.clientY - startY) >= 4;
+
+ if (delFromId && !delDrag && !moved) {
+ showRoomContextMenu(startX, startY, delFromId);
+ clearDelHighlight();
+ dragging = false; dragRoom = false; delFromId = null; delTargetId = null; delDrag = false;
+ return;
+ }
+
+ if (delDrag && delFromId && delTargetId) {
+ deleteLink(delFromId, delTargetId);
+ clearDelHighlight();
+ dragging = false; dragRoom = false; delDrag = false; delFromId = null; delTargetId = null;
+ return;
+ }
+
+ if (linkDrag && linkFromId) {
+ if (linkTargetId) {
+ createLink(linkFromId, linkTargetId);
+ } else if (linkGhostDir) {
+ createRoom(linkFromId, linkGhostDir);
+ }
+ } else if (!moved && !delDrag) {
+ if (e.target.classList.contains('rm')) {
+ selectRoom(parseInt(e.target.getAttribute('data-id')));
+ } else if (e.target.classList.contains('ghost')) {
+ var dir = e.target.getAttribute('data-dir');
+ createRoom(selectedRoom, dir);
+ }
+ }
+
+ clearLinkHighlight();
+ clearDelHighlight();
+ dragging = false; dragRoom = false;
+ linkDrag = false; linkFromId = null; linkTargetId = null; linkGhostDir = null;
+ delDrag = false; delFromId = null; delTargetId = null;
+ };
+
+ svg.onwheel = function(e) {
+ e.preventDefault();
+ var zf = e.deltaY < 0 ? 1.1 : 0.9;
+ scale *= zf;
+ if (scale < 0.1) scale = 0.1;
+ if (scale > 5) scale = 5;
+ updateView();
+ };
+
+ svg.querySelectorAll('rect.rm').forEach(function(r) {
+ r.addEventListener('mousemove', function(ev) {
+ var tip = $('#tooltip');
+ tip.style.display = 'block';
+ tip.style.left = (ev.clientX+12)+'px';
+ tip.style.top = (ev.clientY+12)+'px';
+ tip.textContent = '#'+r.getAttribute('data-id')+' '+r.getAttribute('data-name');
+ });
+ r.addEventListener('mouseleave', function() {
+ $('#tooltip').style.display = 'none';
+ });
+ });
+}
+
+function wrapText(name, cx, cy, maxWidth, color) {
+ if (!name) name = '';
+ color = color || '#fff';
+ var charW = 6;
+ var maxLen = Math.max(4, Math.floor((maxWidth - 4) / charW));
+ var lines = [];
+ for (var i = 0; i < name.length; i += maxLen) {
+ lines.push(name.substring(i, i + maxLen));
+ }
+ if (lines.length > 3) lines = lines.slice(0, 3);
+ var dy = -(lines.length - 1) * 6;
+ return lines.map(function(l, i) {
+ return '<text x="'+cx+'" y="'+(cy+dy+i*12)+'" text-anchor="middle" font-size="10" fill="'+color+'" pointer-events="none" font-family="monospace">'+esc(l)+'</text>';
+ }).join('');
+}
+
+function updateView() {
+ var svg = document.querySelector('#mapSvg svg');
+ if (!svg) return;
+ var W = svg.parentElement.clientWidth;
+ var H = svg.parentElement.clientHeight;
+ var vb = [(-panX - W/2)/scale, (-panY - H/2)/scale, W/scale, H/scale];
+ svg.setAttribute('viewBox', vb.join(' '));
+}
+
+function selectRoom(id) {
+ selectedRoom = id;
+ loadMap();
+ API.get('/api/rooms/' + id).then(function(data) {
+ renderSidePanel(data);
+ }).catch(function(e) {
+ notify('Failed to load room: '+e.message, 'error');
+ });
+}
+
+function renderSidePanel(data) {
+ var panel = $('#panelContent');
+ if (!data || !data.room) {
+ panel.innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room not found</p>';
+ return;
+ }
+ var r = data.room;
+ var file = data.file || '';
+ var html = '<div class="form-group"><label>File</label><span style="font-size:11px;color:#888">' + esc(file) + '</span></div>';
+
+ html += '<div class="form-group"><label>ID</label><input id="roomID" type="number" value="' + r.id + '" onchange="autoSave()"></div>';
+ html += '<div class="form-group"><label>Name</label><input id="roomName" value="' + escAttr(r.name || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group color-field"><label>Color</label><span class="color-swatch" style="background:'+resolveColor(r.color)+'"></span><input id="roomColor" value="' + escAttr(r.color || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group"><label>Directory</label><select id="roomDir" onchange="autoSave()"></select></div>';
+ var descText = '';
+ if (Array.isArray(r.description)) {
+ descText = r.description.map(function(d) { return d.text || ''; }).join('\n\n');
+ } else if (typeof r.description === 'string') {
+ descText = r.description;
+ }
+ html += '<div class="form-group"><label>Description</label><textarea id="roomDesc" rows="4" oninput="autoSave()">' + esc(descText) + '</textarea></div>';
+ html += '<div class="form-group"><label>Hazard</label><input id="roomHazard" value="' + escAttr(r.hazard || '') + '" oninput="autoSave()"></div>';
+ html += '<div class="form-group"><label><input type="checkbox" id="roomBlockTransport" onchange="autoSave()"' + (r.block_transport ? ' checked' : '') + '> Block Transport</label></div>';
+
+ panel.innerHTML = html;
+ setTimeout(function() {
+ var cf = document.querySelector('#panelContent .color-field');
+ if (cf) {
+ bindColorField(cf);
+ var ci = cf.querySelector('input');
+ if (ci) ci.addEventListener('input', function() { autoSave(); });
+ }
+ loadRoomDirs(function(dirs) {
+ var sel = $('#roomDir');
+ if (!sel) return;
+ var current = (file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || '';
+ dirs.forEach(function(d) {
+ var opt = document.createElement('option');
+ opt.value = d;
+ opt.textContent = d || '(root)';
+ if (d === current) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ });
+ }, 50);
+}
+
+function autoSave() {
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(function() { doSavePanel(); }, 600);
+}
+
+function doSavePanel() {
+ var id = selectedRoom;
+ if (!id) return;
+ API.get('/api/rooms/' + id).then(function(data) {
+ var r = data.room || {};
+ r.id = id;
+ var el = $('#roomName'); if (el) r.name = el.value;
+ el = $('#roomColor'); if (el) r.color = el.value;
+ el = $('#roomDesc'); if (el && el.value) r.description = [{text: el.value}];
+ el = $('#roomHazard'); if (el) r.hazard = el.value;
+ el = $('#roomBlockTransport'); if (el) r.block_transport = el.checked;
+
+ var newID = parseInt($('#roomID').value) || id;
+ var newDir = $('#roomDir').value;
+ var currentDir = (data.file || '').replace(/^data\/rooms\/?/, '').split('/')[0] || '';
+
+ var save = function() {
+ API.put('/api/rooms/' + id, r).then(function() {
+ updateUndoBar();
+ }).catch(function(e) { notify('Save failed: '+e.message, 'error'); });
+ };
+
+ if (newDir !== currentDir && newDir !== undefined) {
+ API.post('/api/rooms/move', {id: id, dir: newDir}).then(function() {
+ if (newID !== id) {
+ API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() {
+ selectRoom(newID);
+ }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); });
+ } else { save(); }
+ }).catch(function(e) { notify('Move failed: '+e.message, 'error'); save(); });
+ } else if (newID !== id) {
+ API.post('/api/rooms/rename', {id: id, new_id: newID}).then(function() {
+ selectRoom(newID);
+ }).catch(function(e) { notify('Rename failed: '+e.message, 'error'); save(); });
+ } else { save(); }
+ });
+}
+
+function deleteRoom(id) {
+ if (!confirm('Delete room #' + id + '?')) return;
+ if (mapData && mapData.rooms) {
+ var neighbors = [];
+ if (mapData.links) {
+ mapData.links.forEach(function(l) {
+ if (l.from === id && l.to !== id) neighbors.push(l.to);
+ if (l.to === id && l.from !== id) neighbors.push(l.from);
+ });
+ }
+ if (neighbors.length > 1) {
+ var remaining = mapData.rooms.filter(function(r) { return r.id !== id; });
+ var adj = {};
+ remaining.forEach(function(r) { adj[r.id] = []; });
+ if (mapData.links) {
+ mapData.links.forEach(function(l) {
+ if (l.from === id || l.to === id) return;
+ if (adj[l.from]) adj[l.from].push(l.to);
+ if (adj[l.to]) adj[l.to].push(l.from);
+ });
+ }
+ if (remaining.length > 0) {
+ var visited = {}, queue = [remaining[0].id];
+ visited[remaining[0].id] = true;
+ while (queue.length > 0) {
+ var cur = queue.shift();
+ (adj[cur] || []).forEach(function(n) { if (!visited[n]) { visited[n] = true; queue.push(n); } });
+ }
+ if (Object.keys(visited).length < remaining.length) {
+ if (!confirm('Deleting room #'+id+' will split the map into disconnected areas. Continue?')) return;
+ }
+ }
+ }
+ }
+ API.del('/api/rooms/' + id).then(function() {
+ notify('Room #' + id + ' deleted', 'success');
+ updateUndoBar();
+ selectedRoom = null;
+ $('#panelContent').innerHTML = '<p style="color:#888;text-align:center;margin-top:40px">Room deleted</p>';
+ loadMap();
+ }).catch(function(e) { notify('Delete failed: ' + e.message, 'error'); });
+}
+
+function createRoom(fromID, dir) {
+ API.get('/api/next-room-id?from=' + fromID).then(function(r) {
+ var newID = r.id;
+ var name = 'Room #' + newID;
+ var body = { name: name, link_from: fromID, link_dir: dir };
+ var srcRoom = roomMap[fromID];
+ if (srcRoom && srcRoom.color) body.color = srcRoom.color;
+ API.post('/api/rooms', body).then(function(resp) {
+ var createdId = (resp && resp.room && resp.room.id) || newID;
+ notify('Room #' + createdId + ' created', 'success');
+ updateUndoBar();
+ selectRoom(createdId);
+ }).catch(function(e) { notify('Create failed: ' + e.message, 'error'); });
+ });
+}
+
+function showRoomContextMenu(x, y, id) {
+ var overlay = document.createElement('div');
+ overlay.className = 'cm-overlay';
+ overlay.onclick = function() { overlay.remove(); menu.remove(); };
+
+ var menu = document.createElement('div');
+ menu.className = 'cm-menu';
+ menu.style.left = x + 'px';
+ menu.style.top = y + 'px';
+
+ var delBtn = document.createElement('button');
+ delBtn.textContent = 'Delete Room';
+ delBtn.className = 'danger';
+ delBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ deleteRoom(id);
+ };
+ menu.appendChild(delBtn);
+
+ var selBtn = document.createElement('button');
+ selBtn.textContent = 'Room Details';
+ selBtn.onclick = function() {
+ overlay.remove(); menu.remove();
+ selectRoom(id);
+ };
+ menu.appendChild(selBtn);
+
+ document.body.appendChild(overlay);
+ document.body.appendChild(menu);
+}
+
+function mouseToGrid(e) {
+ var svgEl = document.querySelector('#mapSvg svg');
+ if (!svgEl) return null;
+ var rect = svgEl.getBoundingClientRect();
+ var mx = e.clientX - rect.left;
+ var my = e.clientY - rect.top;
+ if (mx < 0 || my < 0 || mx > rect.width || my > rect.height) return null;
+ var vb = svgEl.getAttribute('viewBox');
+ if (!vb) return null;
+ var parts = vb.split(' ');
+ var vbX = parseFloat(parts[0]), vbY = parseFloat(parts[1]);
+ var vbW = parseFloat(parts[2]), vbH = parseFloat(parts[3]);
+ var wx = vbX + mx * vbW / rect.width;
+ var wy = vbY + my * vbH / rect.height;
+ return {
+ x: Math.floor(wx / CELL),
+ y: Math.floor(wy / CELL)
+ };
+}
+
+function gridDeltaToDir(dx, dy) {
+ for (var i = 0; i < gridDirs.length; i++) {
+ if (gridDirs[i].dx === dx && gridDirs[i].dy === dy) return gridDirs[i].dir;
+ }
+ return null;
+}
+
+function updateLinkDragTarget(e) {
+ clearLinkHighlight();
+ linkTargetId = null;
+ linkGhostDir = null;
+
+ var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === linkFromId;}) : null;
+ if (!fromRoom) return;
+
+ var gp = mouseToGrid(e);
+ if (!gp) return;
+
+ var neighbor = occupied[gp.x+','+gp.y];
+ var gdx = gp.x - fromRoom.x;
+ var gdy = gp.y - fromRoom.y;
+ if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return;
+ if (gdx === 0 && gdy === 0) return;
+ var dir = gridDeltaToDir(gdx, gdy);
+ if (!dir) return;
+
+ if (neighbor && neighbor !== linkFromId) {
+ linkTargetId = neighbor;
+ linkGhostDir = null;
+ applyLinkHighlight(linkFromId, linkTargetId);
+ } else if (!neighbor) {
+ linkGhostDir = dir;
+ linkTargetId = null;
+ applyGhostLinkHighlight(fromRoom, gp.x, gp.y);
+ } else {
+ linkGhostDir = null;
+ }
+}
+
+function applyGhostLinkHighlight(fromRoom, gx, gy) {
+ clearLinkHighlight();
+ linkTargetId = null;
+
+ var fromEl = document.querySelector('.rm[data-id="'+linkFromId+'"]');
+ if (!fromEl) return;
+ fromEl.setAttribute('stroke', '#0ff');
+ fromEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__hl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', gx * CELL + CELL/2);
+ line.setAttribute('y2', gy * CELL + CELL/2);
+ line.setAttribute('stroke', '#0ff');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function applyLinkHighlight(fromId, toId) {
+ clearLinkHighlight();
+
+ var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]');
+ var toEl = document.querySelector('.rm[data-id="'+toId+'"]');
+ var fromRoom = roomMap[fromId];
+ var toRoom = roomMap[toId];
+ if (!fromEl || !toEl || !fromRoom || !toRoom) return;
+
+ fromEl.setAttribute('stroke', '#0ff');
+ fromEl.setAttribute('stroke-width', '3');
+ toEl.setAttribute('stroke', '#f0f');
+ toEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__hl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', toRoom.x * CELL + CELL/2);
+ line.setAttribute('y2', toRoom.y * CELL + CELL/2);
+ line.setAttribute('stroke', '#f0f');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function clearLinkHighlight() {
+ var line = document.getElementById('__hl_line');
+ if (line) line.remove();
+
+ if (linkFromId) {
+ var el = document.querySelector('.rm[data-id="'+linkFromId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === linkFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === linkFromId ? '3' : '1.5');
+ }
+ }
+ if (linkTargetId) {
+ var el = document.querySelector('.rm[data-id="'+linkTargetId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === linkTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === linkTargetId ? '3' : '1.5');
+ }
+ }
+}
+
+function createLink(fromId, toId) {
+ API.post('/api/rooms/link', {from: fromId, to: toId}).then(function() {
+ notify('Linked rooms #'+fromId+' '+toId, 'success');
+ updateUndoBar();
+ loadMap();
+ if (selectedRoom) {
+ API.get('/api/rooms/' + selectedRoom).then(function(data) {
+ renderSidePanel(data);
+ });
+ }
+ }).catch(function(e) {
+ notify('Link failed: ' + e.message, 'error');
+ });
+}
+
+function updateDelDragTarget(e) {
+ clearDelHighlight();
+ delTargetId = null;
+
+ var fromRoom = mapData.rooms ? mapData.rooms.find(function(r){return r.id === delFromId;}) : null;
+ if (!fromRoom) return;
+
+ var gp = mouseToGrid(e);
+ if (!gp) return;
+
+ var neighbor = occupied[gp.x+','+gp.y];
+ if (!neighbor || neighbor === delFromId) return;
+
+ var gdx = gp.x - fromRoom.x;
+ var gdy = gp.y - fromRoom.y;
+ if (Math.abs(gdx) > 1 || Math.abs(gdy) > 1) return;
+ if (gdx === 0 && gdy === 0) return;
+ if (!gridDeltaToDir(gdx, gdy)) return;
+
+ delTargetId = neighbor;
+ applyDelHighlight(delFromId, delTargetId);
+}
+
+function applyDelHighlight(fromId, toId) {
+ clearDelHighlight();
+
+ var fromEl = document.querySelector('.rm[data-id="'+fromId+'"]');
+ var toEl = document.querySelector('.rm[data-id="'+toId+'"]');
+ var fromRoom = roomMap[fromId];
+ var toRoom = roomMap[toId];
+ if (!fromEl || !toEl || !fromRoom || !toRoom) return;
+
+ fromEl.setAttribute('stroke', '#f55');
+ fromEl.setAttribute('stroke-width', '3');
+ toEl.setAttribute('stroke', '#f55');
+ toEl.setAttribute('stroke-width', '3');
+
+ var g = document.querySelector('#mapSvg svg g');
+ if (!g) return;
+ var svgNS = 'http://www.w3.org/2000/svg';
+ var line = document.createElementNS(svgNS, 'line');
+ line.id = '__dl_line';
+ line.setAttribute('x1', fromRoom.x * CELL + CELL/2);
+ line.setAttribute('y1', fromRoom.y * CELL + CELL/2);
+ line.setAttribute('x2', toRoom.x * CELL + CELL/2);
+ line.setAttribute('y2', toRoom.y * CELL + CELL/2);
+ line.setAttribute('stroke', '#f55');
+ line.setAttribute('stroke-width', '3');
+ line.setAttribute('stroke-dasharray', '6,3');
+ line.setAttribute('pointer-events', 'none');
+ g.appendChild(line);
+}
+
+function clearDelHighlight() {
+ var line = document.getElementById('__dl_line');
+ if (line) line.remove();
+
+ if (delFromId) {
+ var el = document.querySelector('.rm[data-id="'+delFromId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === delFromId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === delFromId ? '3' : '1.5');
+ }
+ }
+ if (delTargetId) {
+ var el = document.querySelector('.rm[data-id="'+delTargetId+'"]');
+ if (el) {
+ el.setAttribute('stroke', selectedRoom === delTargetId ? '#fff' : (el.getAttribute('fill') || '#3a3a6a'));
+ el.setAttribute('stroke-width', selectedRoom === delTargetId ? '3' : '1.5');
+ }
+ }
+}
+
+function deleteLink(fromId, toId) {
+ if (mapData && mapData.links) {
+ var checkOrphan = function(roomId, otherId) {
+ var room = roomMap[roomId];
+ if (!room) return false;
+ var otherLinks = mapData.links.filter(function(l) {
+ return (l.from === roomId || l.to === roomId) && !((l.from === roomId && l.to === otherId) || (l.from === otherId && l.to === roomId));
+ });
+ if (otherLinks.length === 0) {
+ if (!confirm('Removing this link will orphan room #'+roomId+' and it will disappear from the map. Continue?')) return false;
+ }
+ return true;
+ };
+ if (!checkOrphan(toId, fromId)) return;
+ if (!checkOrphan(fromId, toId)) return;
+ }
+
+ fetch('/api/rooms/link', {
+ method: 'DELETE',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({from: fromId, to: toId}),
+ credentials: 'same-origin'
+ }).then(function(r) {
+ if (!r.ok) throw new Error('unlink failed');
+ return r.json();
+ }).then(function() {
+ notify('Removed link #'+fromId+' '+toId, 'success');
+ updateUndoBar();
+ loadMap();
+ if (selectedRoom) {
+ API.get('/api/rooms/' + selectedRoom).then(function(data) {
+ renderSidePanel(data);
+ });
+ }
+ }).catch(function(e) {
+ notify('Delete link failed: ' + e.message, 'error');
+ });
+}
+
+function esc(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function escAttr(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;');
+}
+
+window.refreshPage = function() { loadMap(); if (selectedRoom) selectRoom(selectedRoom); };
+
+function loadRoomDirs(cb) {
+ API.get('/api/room-dirs').then(function(dirs) {
+ var sel = $('#dirSelect');
+ if (sel && sel.options.length === 0) {
+ dirs.forEach(function(d) {
+ var opt = document.createElement('option');
+ opt.value = d;
+ opt.textContent = d || '(root)';
+ sel.appendChild(opt);
+ });
+ }
+ updateDirSelect();
+ if (cb) cb(dirs);
+ });
+}
+
+function updateDirSelect() {
+ var sel = $('#dirSelect');
+ if (sel && currentDir !== undefined) {
+ sel.value = currentDir;
+ }
+}
+
+document.addEventListener('DOMContentLoaded', function() { loadRoomDirs(function() { loadMap(); }); });
diff --git a/internal/admin/static/talktree.js b/internal/admin/static/talktree.js
new file mode 100644
index 0000000..f254c21
--- /dev/null
+++ b/internal/admin/static/talktree.js
@@ -0,0 +1,92 @@
+function renderTalkTree(data) {
+ var container = document.getElementById('talktree');
+ if (!container) return;
+ if (!data || !data.talk || !data.talk.nodes) {
+ container.innerHTML = '<p style="color:#888">No conversation tree defined.</p>';
+ return;
+ }
+ window._talkData = data;
+ var nodes = data.talk.nodes;
+ var html = '<div class="talk-tree">';
+ html += renderTreeNodes(nodes, 'start', {});
+ html += '</div>';
+ container.innerHTML = html;
+}
+
+function renderTreeNodes(nodes, rootKey, seen) {
+ var node = nodes[rootKey];
+ if (!node) return '';
+ if (seen[rootKey]) {
+ return '<div class="node ref"><span class="key">' + esc(rootKey) + '</span> <span class="preview">(already shown)</span></div>';
+ }
+ seen[rootKey] = true;
+
+ var html = '';
+ html += '<div class="node root' + (rootKey === 'start' ? ' open' : '') + '">';
+ html += '<div class="node-header" onclick="this.parentElement.classList.toggle(\'open\')">';
+ html += '<span class="arrow"></span>';
+ html += '<span class="key">' + esc(rootKey) + '</span>';
+ html += '<span class="preview">' + esc((node.message || '').substring(0, 60)) + '</span>';
+ html += '</div>';
+ html += '<div class="node-children">';
+
+ html += '<div style="margin-left:8px;font-size:11px">';
+ if (node.message) html += '<div><b>Message:</b> ' + esc(node.message) + '</div>';
+ if (node.condition) html += '<div><b>Condition:</b> ' + esc(JSON.stringify(node.condition)) + '</div>';
+ if (node.goto) html += '<div><b>Goto:</b> ' + esc(node.goto) + '</div>';
+ html += '</div>';
+
+ html += '<div style="margin-left:10px"><b style="font-size:11px;color:#aaa">Options:</b>';
+ if (node.options && node.options.length > 0) {
+ node.options.forEach(function(opt, idx) {
+ html += '<div class="option">';
+ html += '<span>' + esc(opt.text || '') + '</span> \u2192 <span style="color:#8cf">' + esc(opt.goto || '') + '</span>';
+ if (opt.condition) html += ' <span style="color:#888;font-size:10px">[cond]</span>';
+ if (opt.action) html += ' <span style="color:#888;font-size:10px">[action]</span>';
+ html += ' <button class="btn btn-danger btn-sm" onclick="deleteOption(event,\'' + escAttr(rootKey) + '\',' + idx + ')">Delete Option</button>';
+ html += '</div>';
+ });
+ } else {
+ html += '<div class="option" style="color:#888">No options</div>';
+ }
+ html += '<div style="margin-top:4px"><button class="btn btn-primary btn-sm" onclick="addOption(event,\'' + escAttr(rootKey) + '\')">+ Add Option</button></div>';
+ html += '</div>';
+
+ html += '</div></div>';
+
+ if (node.options) {
+ node.options.forEach(function(opt) {
+ if (opt.goto && nodes[opt.goto]) {
+ html += renderTreeNodes(nodes, opt.goto, seen);
+ }
+ });
+ }
+
+ return html;
+}
+
+function addOption(e, nodeKey) {
+ if (e) e.stopPropagation();
+ var data = window._talkData;
+ if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
+ var text = prompt('Option text:');
+ if (text === null) return;
+ var dest = prompt('Goto node key:') || '';
+ var node = data.talk.nodes[nodeKey];
+ if (!node.options) node.options = [];
+ node.options.push({ text: text, goto: dest });
+ renderTalkTree(data);
+ if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
+}
+
+function deleteOption(e, nodeKey, idx) {
+ if (e) e.stopPropagation();
+ var data = window._talkData;
+ if (!data || !data.talk || !data.talk.nodes || !data.talk.nodes[nodeKey]) return;
+ var node = data.talk.nodes[nodeKey];
+ if (!node.options || idx < 0 || idx >= node.options.length) return;
+ if (!confirm('Delete this option?')) return;
+ node.options.splice(idx, 1);
+ renderTalkTree(data);
+ if (typeof window.onTalkTreeChange === 'function') window.onTalkTreeChange(data);
+}