// ============================================================ // admin-docs.js — admin-only docs viewer. // // Lazy-loads /api/admin/docs/tree once on first tab activation, // renders a collapsible file tree in the sidebar. Clicking a .md // node fetches /api/admin/docs/file?path=X and renders the // pre-sanitised HTML returned by the server (server uses `marked`). // // The route is gated to role=admin server-side. The sidebar tab // button (#docs-tab-btn) is hidden by default and revealed by // auth.js after login when user.role === 'admin'. // ============================================================ var _inited = false; var _treeLoaded = false; var _tree = []; var _flatFiles = []; // flat list of {name, path, parent} for filter var _expanded = {}; // map of dir-path → bool, persisted in UIState var _currentPath = ''; function $(id) { return document.getElementById(id); } function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // Pretty label from a file/dir basename. Drops .md, replaces dashes // with spaces, title-cases the first letter of each word. README is // shown as "Index" so the entry-point doc is more obvious in the tree. function prettyName(name, isDir) { if (!isDir && /^readme\.md$/i.test(name)) return 'Index'; var base = name.replace(/\.md$/i, ''); return base.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } // Recursively render the tree into HTML. function renderNode(node, depth) { if (node.type === 'dir') { var open = _expanded[node.path] !== false; // default open var arrow = open ? 'fa-chevron-down' : 'fa-chevron-right'; var label = prettyName(node.name, true); var html = '
' + '' + '
' + (node.children || []).map(function (c) { return renderNode(c, depth + 1); }).join('') + '
' + '
'; return html; } // File node return '
' + '' + '
'; } // Flatten the tree into _flatFiles for the filter to search across. function flatten(nodes, parentLabel) { nodes.forEach(function (n) { if (n.type === 'dir') { flatten(n.children || [], (parentLabel ? parentLabel + ' / ' : '') + prettyName(n.name, true)); } else { _flatFiles.push({ name: prettyName(n.name, false), path: n.path, parent: parentLabel || '' }); } }); } function renderTree() { var host = $('docs-tree'); if (!host) return; if (!_tree.length) { host.innerHTML = '
No docs found.
'; return; } host.innerHTML = _tree.map(function (n) { return renderNode(n, 0); }).join(''); } function renderFilterResults(q) { var host = $('docs-tree'); if (!host) return; var lc = q.toLowerCase(); var matches = _flatFiles.filter(function (f) { return f.name.toLowerCase().indexOf(lc) !== -1 || f.path.toLowerCase().indexOf(lc) !== -1 || f.parent.toLowerCase().indexOf(lc) !== -1; }); if (matches.length === 0) { host.innerHTML = '
No matches for "' + escHtml(q) + '".
'; return; } host.innerHTML = matches.map(function (f) { var parent = f.parent ? '' + escHtml(f.parent) + '' : ''; return '
' + '' + '
'; }).join(''); } // marked no longer guarantees heading ids across versions, and the docs // reader is an internal scroll container. Add stable GitHub-style ids and // handle same-page #toc links by scrolling the reader, not the window. function slugHeading(text) { return String(text || '') .trim() .toLowerCase() .replace(/\s/g, '-') .replace(/[^a-z0-9_-]/g, ''); } function prepareDocAnchors(body) { if (!body) return; var seen = {}; body.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) { var base = slugHeading(h.textContent || '') || h.id; if (!base) return; var id = base; var n = seen[base] || 0; if (n) id = base + '-' + n; seen[base] = n + 1; h.id = id; }); } function prepareDocLinks(body) { if (!body) return; body.querySelectorAll('a[href]').forEach(function (link) { var href = link.getAttribute('href') || ''; if (href.charAt(0) === '#') { link.dataset.docHash = href; link.classList.add('docs-anchor-link'); return; } if (/\.md(#.*)?$/i.test(href)) { var parts = href.split('#'); link.dataset.docFile = resolveDocPath(parts[0] || _currentPath); if (parts[1]) link.dataset.docHash = '#' + parts[1]; link.classList.add('docs-anchor-link'); return; } if (link.hash) { try { var u = new URL(link.href, window.location.href); if (u.origin !== window.location.origin || u.pathname !== window.location.pathname) return; link.dataset.docHash = u.hash; link.classList.add('docs-anchor-link'); } catch (_) { return; } } }); } function resolveDocPath(href) { if (!href) return _currentPath; if (href.charAt(0) === '/') return href.replace(/^\/+/, ''); var base = _currentPath.split('/'); base.pop(); href.split('/').forEach(function (part) { if (!part || part === '.') return; if (part === '..') base.pop(); else base.push(part); }); return base.join('/'); } function scrollReaderToHash(hash) { var body = $('docs-reader-body'); var reader = $('docs-reader'); if (!body || !reader || !hash) return false; var rawId = String(hash).replace(/^#/, ''); var id; try { id = decodeURIComponent(rawId); } catch (_) { id = rawId; } if (!id) return false; var target = document.getElementById(id); if (!target || !body.contains(target)) { target = Array.prototype.slice.call(body.querySelectorAll('h1,h2,h3,h4,h5,h6')).find(function (h) { return slugHeading(h.textContent || '') === id; }); } if (!target || !body.contains(target)) return false; var readerBox = reader.getBoundingClientRect(); var targetBox = target.getBoundingClientRect(); reader.scrollTop += targetBox.top - readerBox.top - 8; return true; } function handleDocAnchorClick(e) { var body = $('docs-reader-body'); var link = e.target.closest('a, [data-doc-hash], [data-doc-file]'); if (!link || !body || !body.contains(link)) return false; var href = link.dataset.docHash || link.getAttribute('href') || ''; var file = link.dataset.docFile || ''; if (!file && href && /\.md(#.*)?$/i.test(href)) { var parts = href.split('#'); file = resolveDocPath(parts[0] || _currentPath); href = parts[1] ? '#' + parts[1] : ''; } if (!file && (!href || href.charAt(0) !== '#')) return false; e.preventDefault(); if (file && file !== _currentPath) { loadFile(file, href); markActive(file); return true; } requestAnimationFrame(function () { if (scrollReaderToHash(href)) { try { history.replaceState(null, '', href); } catch (_) {} } }); return true; } function handleDocAnchorKeydown(e) { if (e.key !== 'Enter' && e.key !== ' ') return; if (handleDocAnchorClick(e)) e.preventDefault(); } function loadTree() { if (_treeLoaded) return; fetch('/api/admin/docs/tree', { headers: getAuthHeaders() }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data || !data.success) { $('docs-tree').innerHTML = '
' + escHtml((data && data.error) || 'Tree load failed') + '
'; return; } _tree = data.tree || []; _flatFiles = []; flatten(_tree, ''); // Restore expanded state from UIState before render try { var saved = window.UIState && window.UIState.get('docs.expanded'); if (saved) _expanded = JSON.parse(saved); } catch (e) {} renderTree(); _treeLoaded = true; // Auto-load the top README if available, else the first file var first = _flatFiles[0]; if (first) { var savedPath = (window.UIState && window.UIState.get('docs.lastPath')) || first.path; loadFile(savedPath); markActive(savedPath); } }) .catch(function (err) { $('docs-tree').innerHTML = '
Tree load failed: ' + escHtml(err.message || String(err)) + '
'; }); } function loadFile(relPath, hash) { var body = $('docs-reader-body'); var meta = $('docs-reader-meta'); if (!body) return; _currentPath = relPath; body.innerHTML = '
Loading…
'; fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data || !data.success) { body.innerHTML = '

' + escHtml((data && data.error) || 'Load failed') + '

'; return; } body.innerHTML = data.html || ''; prepareDocAnchors(body); prepareDocLinks(body); if (meta) { meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : ''); } // Persist last opened if (window.UIState) window.UIState.set('docs.lastPath', relPath); // Scroll content area to top so deep-link readers don't land mid-doc var reader = $('docs-reader'); if (reader) reader.scrollTop = 0; var targetHash = hash || window.location.hash; if (targetHash) setTimeout(function () { scrollReaderToHash(targetHash); }, 0); }) .catch(function (err) { body.innerHTML = '

' + escHtml(err.message || String(err)) + '

'; }); } function markActive(relPath) { document.querySelectorAll('.docs-file-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.file === relPath); }); } function persistExpanded() { if (!window.UIState) return; try { window.UIState.set('docs.expanded', JSON.stringify(_expanded)); } catch (e) {} } // ── Wire events ──────────────────────────────────────────────────── function init() { document.addEventListener('click', function (e) { if (handleDocAnchorClick(e)) return; var fileBtn = e.target.closest('.docs-file-btn'); if (fileBtn) { var p = fileBtn.dataset.file; if (p) { loadFile(p); markActive(p); } return; } var dirToggle = e.target.closest('.docs-dir-toggle'); if (dirToggle) { var dir = dirToggle.dataset.toggle; var node = dirToggle.closest('.docs-dir'); var children = node && node.querySelector('.docs-children'); var arrow = dirToggle.querySelector('.docs-arrow'); var willOpen = !(_expanded[dir] !== false); // toggle _expanded[dir] = willOpen; if (children) { if (willOpen) children.removeAttribute('hidden'); else children.setAttribute('hidden', ''); } if (arrow) { arrow.classList.toggle('fa-chevron-down', willOpen); arrow.classList.toggle('fa-chevron-right', !willOpen); } persistExpanded(); return; } }); var body = $('docs-reader-body'); if (body && !body.dataset.anchorsWired) { body.dataset.anchorsWired = '1'; body.addEventListener('click', handleDocAnchorClick); body.addEventListener('keydown', handleDocAnchorKeydown); } var filter = $('docs-filter'); if (filter) { var t = null; filter.addEventListener('input', function () { clearTimeout(t); t = setTimeout(function () { var q = filter.value.trim(); if (!q) renderTree(); else renderFilterResults(q); }, 120); }); } } document.addEventListener('tabChanged', function (e) { if (!e.detail || e.detail.tab !== 'docs') return; if (!_inited) { _inited = true; init(); } loadTree(); }); console.log('Admin docs viewer loaded');