From 06dea70ed1ff014afed53094bd2762c7f498590f Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Sat, 24 Oct 2026 03:29:54 +0200 Subject: [PATCH] Add collapsible JSON tree to admin views --- index.html | 233 ++++++++++++++++++------ scripts/check-frontend.sh | 10 +- tests/admin-conversation-trace.test.cjs | 29 ++- 3 files changed, 206 insertions(+), 66 deletions(-) diff --git a/index.html b/index.html index b62efa9..33e12a2 100644 --- a/index.html +++ b/index.html @@ -36,21 +36,39 @@ #admin-content .admin-simple-conversation article > .admin-story-reply { display: block; margin-block: .85rem; white-space: pre-wrap; } #admin-content .admin-simple-conversation article > .admin-story-player { font-weight: 650; } #admin-content pre, #debug-trace pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; } - .json-view { box-sizing: border-box; margin-block: .65rem; padding: .8rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; tab-size: 2; font: .84rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } - .json-token-key { color: #075985; font-weight: 700; } - .json-token-string { color: #166534; } - .json-token-number { color: #6d28d9; font-weight: 650; } - .json-token-boolean { color: #b45309; font-weight: 750; } - .json-token-null { color: #be123c; font-style: italic; } - .json-token-punctuation { color: #64748b; } + .json-view { box-sizing: border-box; margin-block: .65rem; padding: .65rem; border: 1px solid #cbd5e1; border-radius: .4rem; background: #f8fafc; color: #1e293b; font: .88rem/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } + .json-tree-tools { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-end: .6rem; } + .json-tree-tools button { font-size: .88rem; padding: .3rem .55rem; } + .json-tree, .json-children { display: grid; gap: .3rem; } + .json-children { margin-inline-start: .7rem; padding-inline-start: .65rem; border-inline-start: 2px solid #cbd5e1; } + .json-node { min-width: 0; } + .json-node > summary { cursor: pointer; padding: .3rem .45rem; border-radius: .25rem; background: #e2e8f0; overflow-wrap: anywhere; } + .json-node > summary:focus-visible, .json-tree-tools button:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; } + .json-label { color: #075985; font-weight: 750; } + .json-kind { color: #475569; font-size: .86em; } + .json-entry { display: grid; grid-template-columns: minmax(8rem, .35fr) minmax(0, 1fr); gap: .5rem; padding: .2rem .45rem; border-radius: .2rem; overflow-wrap: anywhere; } + .json-entry:nth-child(even) { background: #f1f5f9; } + .json-key { color: #075985; font-weight: 700; } + .json-value-string { color: #166534; white-space: pre-wrap; } + .json-value-number { color: #6d28d9; font-weight: 650; } + .json-value-boolean { color: #b45309; font-weight: 750; } + .json-value-null { color: #be123c; font-style: italic; } + .json-empty { padding: .25rem .65rem; color: #475569; font-style: italic; } + .json-long-value { border-inline-start: 2px solid #94a3b8; margin-inline-start: .5rem; } + .json-long-value > summary { cursor: pointer; padding: .3rem .45rem; overflow-wrap: anywhere; } + .json-long-text { margin: .2rem .5rem .5rem; padding: .5rem; max-height: 24rem; overflow: auto; background: #e2e8f0; white-space: pre-wrap; overflow-wrap: anywhere; } + @media (max-width: 42rem) { .json-entry { grid-template-columns: 1fr; gap: .05rem; } } @media (prefers-color-scheme: dark) { .json-view { border-color: #475569; background: #111827; color: #e5e7eb; } - .json-token-key { color: #7dd3fc; } - .json-token-string { color: #86efac; } - .json-token-number { color: #c4b5fd; } - .json-token-boolean { color: #fcd34d; } - .json-token-null { color: #fda4af; } - .json-token-punctuation { color: #94a3b8; } + .json-children { border-inline-start-color: #475569; } + .json-node > summary { background: #263244; } + .json-label, .json-key { color: #7dd3fc; } + .json-kind, .json-empty { color: #cbd5e1; } + .json-entry:nth-child(even), .json-long-text { background: #1f2937; } + .json-value-string { color: #86efac; } + .json-value-number { color: #c4b5fd; } + .json-value-boolean { color: #fcd34d; } + .json-value-null { color: #fda4af; } } #admin-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: 1rem; } #player-search { display: flex; flex-wrap: wrap; align-items: end; gap: .5rem; } @@ -638,43 +656,154 @@ parent.append(element); return element; } - function appendFormattedJson(pre, value, stringAsText = false) { - pre.className = 'json-view'; - pre.setAttribute('aria-label', 'Formaterat JSON-svar'); - const code = document.createElement('code'); - if (stringAsText && typeof value === 'string') { - code.textContent = value; - pre.replaceChildren(code); - return pre; - } - const source = JSON.stringify(value, null, '\t') ?? 'null'; - const tokens = /(\"(?:\\.|[^\"\\])*\"(?=\s*:))|(\"(?:\\.|[^\"\\])*\")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}\[\],:])|(\s+)/g; - let offset = 0; - for (const match of source.matchAll(tokens)) { - if (match.index > offset) code.append(document.createTextNode(source.slice(offset, match.index))); - const token = match[0]; - if (match[7]) { - code.append(document.createTextNode(token)); - } else { - const span = document.createElement('span'); - span.className = match[1] ? 'json-token-key' - : match[2] ? 'json-token-string' - : match[3] ? 'json-token-number' - : match[4] ? 'json-token-boolean' - : match[5] ? 'json-token-null' : 'json-token-punctuation'; - span.textContent = token; - code.append(span); + function renderJsonTree(container, value) { + container.className = 'json-view'; + container.setAttribute('aria-label', 'Utfällbart JSON-svar'); + const tree = document.createElement('div'); + tree.className = 'json-tree'; + const nodeValues = new WeakMap(); + const controls = document.createElement('div'); + controls.className = 'json-tree-tools'; + const expand = document.createElement('button'); + expand.type = 'button'; + expand.textContent = 'Öppna alla block'; + const collapse = document.createElement('button'); + collapse.type = 'button'; + collapse.textContent = 'Fäll ihop nästlade block'; + controls.append(expand, collapse); + container.replaceChildren(controls, tree); + + const isGroup = item => item !== null && typeof item === 'object'; + const groupSummary = item => Array.isArray(item) + ? `Lista · ${item.length} ${item.length === 1 ? 'post' : 'poster'}` + : `Objekt · ${Object.keys(item).length} fält`; + const addLeaf = (parent, label, item) => { + if (typeof item === 'string' && item.length > 180) { + const details = document.createElement('details'); + details.className = 'json-long-value'; + const summary = document.createElement('summary'); + summary.append(document.createTextNode(`${label ? `${label} · ` : ''}Text · ${item.length} tecken · `)); + const preview = document.createElement('span'); + preview.className = 'json-value-string'; + preview.textContent = `${item.slice(0, 100)}${item.length > 100 ? '…' : ''}`; + summary.append(preview); + const fullText = document.createElement('div'); + fullText.className = 'json-long-text'; + fullText.textContent = item; + details.append(summary, fullText); + parent.append(details); + return; } - offset = match.index + token.length; + const row = document.createElement('div'); + row.className = 'json-entry'; + if (label) { + const key = document.createElement('span'); + key.className = 'json-key'; + key.textContent = label; + row.append(key); + } + const valueNode = document.createElement('span'); + const type = item === null ? 'null' : typeof item; + valueNode.className = `json-value-${type === 'boolean' ? 'boolean' : type}`; + valueNode.textContent = typeof item === 'string' ? JSON.stringify(item) : String(item); + row.append(valueNode); + parent.append(row); + }; + const populate = (details, item, depth) => { + if (details.dataset.loaded === 'true') return; + details.dataset.loaded = 'true'; + const children = document.createElement('div'); + children.className = 'json-children'; + if (depth >= 64) { + const note = document.createElement('div'); + note.className = 'json-empty'; + note.textContent = 'Ytterligare nästling döljs efter 64 nivåer.'; + children.append(note); + } else { + const entries = Array.isArray(item) + ? item.map((child, index) => [`[${index}]`, child]) + : Object.entries(item); + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'json-empty'; + empty.textContent = Array.isArray(item) ? 'Listan är tom.' : 'Objektet saknar fält.'; + children.append(empty); + } + for (const [key, child] of entries) { + if (isGroup(child)) { + const nested = document.createElement('details'); + nested.className = 'json-node'; + nodeValues.set(nested, child); + nested.dataset.depth = String(depth + 1); + const nestedSummary = document.createElement('summary'); + const label = document.createElement('span'); + label.className = 'json-label'; + label.textContent = key; + const kind = document.createElement('span'); + kind.className = 'json-kind'; + kind.textContent = ` · ${groupSummary(child)}`; + nestedSummary.append(label, kind); + nested.append(nestedSummary); + nested.addEventListener('toggle', () => { + if (nested.open) populate(nested, child, depth + 1); + }); + children.append(nested); + } else { + addLeaf(children, key, child); + } + } + } + details.append(children); + }; + + if (isGroup(value)) { + const root = document.createElement('details'); + root.className = 'json-node json-root'; + root.open = true; + const summary = document.createElement('summary'); + const label = document.createElement('span'); + label.className = 'json-label'; + label.textContent = 'Innehåll'; + const kind = document.createElement('span'); + kind.className = 'json-kind'; + kind.textContent = ` · ${groupSummary(value)}`; + summary.append(label, kind); + root.append(summary); + root.addEventListener('toggle', () => { + if (root.open) populate(root, value, 0); + }); + populate(root, value, 0); + tree.append(root); + expand.addEventListener('click', () => { + const openAll = details => { + details.open = true; + if (details === root || details.dataset.loaded === 'true') { + const childGroups = [...details.querySelectorAll(':scope > .json-children > details.json-node')]; + for (const child of childGroups) { + if (child.dataset.loaded !== 'true') { + if (nodeValues.has(child)) populate(child, nodeValues.get(child), Number(child.dataset.depth || 1)); + } + openAll(child); + } + } + }; + openAll(root); + }); + collapse.addEventListener('click', () => { + for (const nested of tree.querySelectorAll('details.json-node:not(.json-root)')) nested.open = false; + root.open = true; + }); + } else { + addLeaf(tree, '', value); + expand.disabled = true; + collapse.disabled = true; } - if (offset < source.length) code.append(document.createTextNode(source.slice(offset))); - pre.replaceChildren(code); - return pre; + return container; } function adminJson(title, value, parent = adminContent) { const details = adminElement('details', undefined, parent); adminElement('summary', title, details); - appendFormattedJson(adminElement('pre', undefined, details), value); + renderJsonTree(adminElement('div', undefined, details), value); return details; } async function loadAdminPlayers(query = '', offset = 0) { @@ -914,7 +1043,7 @@ adminElement('p', 'Redigera profilens JSON. Kända spelvärden valideras av Rust; okända framtida egenskaper bevaras som JSON. Värld och position visas som referens och ändras inte här.'); const profileForm = adminElement('form'); const profileLabel = adminElement('label', 'NPC-profil i JSON', profileForm); - const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, 2); profileLabel.append(profileInput); + const profileInput = document.createElement('textarea'); profileInput.rows = 18; profileInput.cols = 80; profileInput.required = true; profileInput.value = JSON.stringify(npc.profile, null, '\t'); profileLabel.append(profileInput); const profileSave = adminElement('button', 'Spara egenskaper', profileForm); profileSave.type = 'submit'; const profileStatus = adminElement('p'); profileStatus.setAttribute('role', 'status'); profileForm.addEventListener('submit', async event => { @@ -1211,9 +1340,9 @@ const addValue = (name, value) => { const valueHeading = document.createElement('strong'); valueHeading.textContent = name; - const pre = document.createElement('pre'); - appendFormattedJson(pre, value ?? 'Ingen uppgift sparad', true); - card.append(valueHeading, pre); + const json = document.createElement('div'); + renderJsonTree(json, value === undefined ? 'Ingen uppgift sparad' : value); + card.append(valueHeading, json); }; addValue('Indata', input); addValue('Utdata', output); @@ -1271,10 +1400,10 @@ const raw = document.createElement('details'); raw.className = 'trace-raw'; const rawSummary = document.createElement('summary'); - rawSummary.textContent = 'Visa all rå spårdata'; - const pre = document.createElement('pre'); - appendFormattedJson(pre, group); - raw.append(rawSummary, pre); + rawSummary.textContent = 'Visa hela spårdata'; + const json = document.createElement('div'); + renderJsonTree(json, group); + raw.append(rawSummary, json); details.append(summary, chain, raw); debugTrace.append(details); } diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index 156e418..0da6a6b 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -99,10 +99,12 @@ grep -q 'id="debug-panel"' index.html grep -q "query AdminPlayerTrace" index.html grep -q 'adminPlayerTrace(accountId: \$accountId, limit: \$limit, offset: \$offset)' index.html grep -q 'function renderDecisionTrace' index.html -grep -q 'appendFormattedJson(pre, group)' index.html -grep -Fq "JSON.stringify(value, null, '\\t')" index.html -grep -q 'json-token-key' index.html -grep -q 'json-token-string' index.html +grep -q 'function renderJsonTree' index.html +grep -q "'Öppna alla block'" index.html +grep -q "'Fäll ihop nästlade block'" index.html +grep -q "nested.className = 'json-node'" index.html +grep -q 'json-value-string' index.html +grep -q "setAttribute('aria-label', 'Utfällbart JSON-svar')" index.html grep -q 'debug-enabled' index.html grep -q '/api/admin/players?' index.html grep -q 'Välj konto att följa' index.html diff --git a/tests/admin-conversation-trace.test.cjs b/tests/admin-conversation-trace.test.cjs index 7b13fc5..342f6f5 100644 --- a/tests/admin-conversation-trace.test.cjs +++ b/tests/admin-conversation-trace.test.cjs @@ -44,14 +44,23 @@ test('admin can switch to a storyteller-only reading view and return to diagnost }); -test('admin JSON responses use tab-indented syntax-colored accessible output', () => { - assert.match(page, /function appendFormattedJson/); - assert.match(page, /JSON\.stringify\(value, null, '\\t'\)/); - assert.match(page, /json-token-key/); - assert.match(page, /json-token-string/); - assert.match(page, /json-token-number/); - assert.match(page, /json-token-boolean/); - assert.match(page, /json-token-null/); - assert.match(page, /setAttribute\('aria-label', 'Formaterat JSON-svar'\)/); - assert.match(page, /appendFormattedJson\(pre, group\)/); +test('admin JSON responses are readable as accessible collapsible trees', () => { + assert.match(page, /function renderJsonTree/); + assert.match(page, /nested\.className = 'json-node'/); + assert.match(page, /const nodeValues = new WeakMap\(\)/); + assert.match(page, /nodeValues\.set\(nested, child\)/); + assert.match(page, /groupSummary/); + assert.match(page, /Lista · \$\{item\.length\}/); + assert.match(page, /Objekt · \$\{Object\.keys\(item\)\.length\} fält/); + assert.match(page, /Öppna alla block/); + assert.match(page, /Fäll ihop nästlade block/); + assert.match(page, /setAttribute\('aria-label', 'Utfällbart JSON-svar'\)/); + assert.match(page, /json-value-string/); + assert.match(page, /json-value-number/); + assert.match(page, /json-value-boolean/); + assert.match(page, /json-value-null/); + assert.match(page, /renderJsonTree\(json, group\)/); + assert.match(page, /rawSummary\.textContent = 'Visa hela spårdata'/); + assert.match(page, /JSON\.stringify\(npc\.profile, null, '\\t'\)/); + assert.doesNotMatch(page, /jsnview|cdn\.jsdelivr/); });