// ============================================================ // 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'. // ============================================================ (function () { 'use strict'; 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 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 = '
' + escHtml((data && data.error) || 'Load failed') + '
'; return; } body.innerHTML = data.html || ''; 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; }) .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) { 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(); } }); 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'); })();