From 069d3c1c81d71042706372df153254487a765dfc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 29 Jun 2026 22:31:11 -0400 Subject: feat: wip web admin for mapping and crud --- internal/admin/static/admin.css | 102 +++++ internal/admin/static/admin.js | 55 +++ internal/admin/static/colorpicker.js | 197 +++++++++ internal/admin/static/editor.js | 174 ++++++++ internal/admin/static/map.js | 775 +++++++++++++++++++++++++++++++++++ internal/admin/static/talktree.js | 92 +++++ 6 files changed, 1395 insertions(+) create mode 100644 internal/admin/static/admin.css create mode 100644 internal/admin/static/admin.js create mode 100644 internal/admin/static/colorpicker.js create mode 100644 internal/admin/static/editor.js create mode 100644 internal/admin/static/map.js create mode 100644 internal/admin/static/talktree.js (limited to 'internal/admin/static') 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 = '
Failed to load: ' + esc(e.message) + '
'; + }); +} + +function renderEditor(id, data) { + var obj = data; + var html = 'Deleted
'; + 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,'&').replace(//g,'>').replace(/"/g,'"'); } +function escAttr(s) { return String(s || '').replace(/&/g,'&').replace(/"/g,'"'); } + +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 += 'Room not found
'; + return; + } + var r = data.room; + var file = data.file || ''; + var html = 'Room deleted
'; + 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,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function escAttr(s) { + if (!s) return ''; + return String(s).replace(/&/g,'&').replace(/"/g,'"'); +} + +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 = 'No conversation tree defined.
'; + return; + } + window._talkData = data; + var nodes = data.talk.nodes; + var html = '