From 8383931cbab38281403f450a707a8c8880dc047d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20Randall=20Havens=20=E2=96=B3=20The=20Empathic=20Tec?= =?UTF-8?q?hnologist=20=E2=9F=81=20Doctor=20Who=2042?= Date: Sat, 8 Nov 2025 14:55:16 -0600 Subject: [PATCH] Update app.js --- public/app.js | 261 +++++++++++++++++++++++--------------------------- 1 file changed, 121 insertions(+), 140 deletions(-) diff --git a/public/app.js b/public/app.js index cc2a85c..7b39866 100755 --- a/public/app.js +++ b/public/app.js @@ -1,15 +1,6 @@ /* global marked, DOMPurify */ -const $ = (sel, root = document) => root.querySelector(sel); -const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; - -const state = { - index: null, - sidebarOpen: false, // mobile overlay (<=1024) - desktopCollapsed: false, // desktop collapse - sort: "newest", - section: "all", - q: "" -}; +const $ = (s, r=document) => r.querySelector(s); +const $$ = (s, r=document) => [...r.querySelectorAll(s)]; const els = { body: document.body, @@ -17,236 +8,226 @@ const els = { content: $("#content"), viewer: $("#viewer"), tree: $("#tree"), - routeHint: $("#routeHint"), navToggle: $("#navToggle"), filterSection: $("#filterSection"), sortOrder: $("#sortOrder"), searchBox: $("#searchBox"), }; +const state = { + index: null, + section: "all", + sort: "newest", + q: "", + sidebarOpen: false, + desktopCollapsed: false +}; + init(); async function init(){ - // restore preferences - try { - state.desktopCollapsed = localStorage.getItem("desktopCollapsed") === "1"; - } catch {} - - // set collapse classes for initial paint - if (window.matchMedia("(min-width:1025px)").matches) { - els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed); - els.body.classList.add("sidebar-open"); // open on desktop unless collapsed + // ensure libs present before any rendering + const ok = await waitForLibs(3000); + if (!ok){ + els.viewer.innerHTML = `

Markdown renderer failed to load. Check /lib/marked.min.js and /lib/purify.min.js.

`; + return; } - els.navToggle.addEventListener("click", toggleSidebar); + // restore desktop collapse + try { state.desktopCollapsed = localStorage.getItem("desktopCollapsed")==="1"; } catch {} - window.addEventListener("resize", onResizeMode); - window.addEventListener("hashchange", onRoute); - onResizeMode(); // set initial open/closed class for mobile/desktop + wireToggles(); + onResizeMode(); - // Fetch index - try { - const res = await fetch("/index.json", { cache: "no-cache" }); - if (!res.ok) throw new Error(res.status + " " + res.statusText); + // load index + try{ + const res = await fetch("/index.json", { cache:"no-cache" }); + if (!res.ok) throw new Error(res.status); state.index = await res.json(); - } catch (e) { - console.error("Failed to load index.json", e); + }catch(e){ + console.error("index.json load failed", e); els.viewer.innerHTML = `

Could not load index.json

`; return; } - // Build filters buildFilters(); - - // Render tree renderTree(); - // Route initial + window.addEventListener("hashchange", onRoute); onRoute(); } +/* ---------- Lib readiness ---------- */ +function waitForLibs(timeoutMs=3000){ + const start = performance.now(); + return new Promise(resolve=>{ + (function tick(){ + const ready = !!(window.marked && window.DOMPurify); + if (ready) return resolve(true); + if (performance.now() - start > timeoutMs) return resolve(false); + setTimeout(tick, 60); + })(); + }); +} + /* ---------- UI wiring ---------- */ +function wireToggles(){ + els.navToggle.addEventListener("click", ()=>{ + const desktop = window.matchMedia("(min-width:1025px)").matches; + if (desktop){ + state.desktopCollapsed = !els.body.classList.contains("sidebar-collapsed"); + els.body.classList.toggle("sidebar-collapsed"); + try{ localStorage.setItem("desktopCollapsed", state.desktopCollapsed ? "1":"0"); }catch{} + }else{ + state.sidebarOpen = !els.body.classList.contains("sidebar-open"); + els.body.classList.toggle("sidebar-open"); + } + }); + + window.addEventListener("resize", onResizeMode); +} function onResizeMode(){ const desktop = window.matchMedia("(min-width:1025px)").matches; - - if (desktop) { - // Desktop: overlay classes off; use collapsed flag to shift layout + if (desktop){ els.body.classList.remove("sidebar-open"); els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed); - els.navToggle.setAttribute("aria-expanded", (!state.desktopCollapsed).toString()); } else { - // Mobile: collapsed flag irrelevant; use overlay class instead els.body.classList.remove("sidebar-collapsed"); if (!state.sidebarOpen) els.body.classList.remove("sidebar-open"); - els.navToggle.setAttribute("aria-expanded", state.sidebarOpen.toString()); - } -} - -function toggleSidebar(){ - const desktop = window.matchMedia("(min-width:1025px)").matches; - if (desktop){ - state.desktopCollapsed = !els.body.classList.contains("sidebar-collapsed"); - els.body.classList.toggle("sidebar-collapsed"); - els.navToggle.setAttribute("aria-expanded", (!state.desktopCollapsed).toString()); - try { localStorage.setItem("desktopCollapsed", state.desktopCollapsed ? "1" : "0"); } catch {} - } else { - state.sidebarOpen = !els.body.classList.contains("sidebar-open"); - els.body.classList.toggle("sidebar-open"); - els.navToggle.setAttribute("aria-expanded", state.sidebarOpen.toString()); } } function buildFilters(){ - const sections = ["all", ...state.index.sections]; - els.filterSection.innerHTML = sections.map(s => - `` - ).join(""); + const sections = ["all", ...(state.index?.sections ?? [])]; + els.filterSection.innerHTML = sections.map(s=>``).join(""); els.filterSection.value = state.section; - els.filterSection.addEventListener("change", e => { + els.filterSection.addEventListener("change", e=>{ state.section = e.target.value; renderTree(); }); els.sortOrder.value = state.sort; - els.sortOrder.addEventListener("change", e => { + els.sortOrder.addEventListener("change", e=>{ state.sort = e.target.value; renderTree(); }); - els.searchBox.addEventListener("input", e => { + els.searchBox.addEventListener("input", e=>{ state.q = e.target.value.trim().toLowerCase(); renderTree(); }); } +/* ---------- Tree ---------- */ function renderTree(){ if (!state.index) return; const items = state.index.flat.slice(); - // filter - const filtered = items.filter(f => { - const inSection = state.section === "all" || f.path.startsWith(state.section + "/"); + const filtered = items.filter(f=>{ + const inSection = state.section==="all" || f.path.startsWith(state.section + "/"); const inQuery = !state.q || f.title.toLowerCase().includes(state.q) || f.name.toLowerCase().includes(state.q); return inSection && inQuery; }); - // sort - filtered.sort((a,b) => { - if (state.sort === "title") return a.title.localeCompare(b.title, undefined, {sensitivity:"base"}); - if (state.sort === "oldest") return (a.mtime ?? 0) - (b.mtime ?? 0); - return (b.mtime ?? 0) - (a.mtime ?? 0); // newest + filtered.sort((a,b)=>{ + if (state.sort==="title") return a.title.localeCompare(b.title, undefined, {sensitivity:"base"}); + if (state.sort==="oldest") return (a.mtime??0) - (b.mtime??0); + return (b.mtime??0) - (a.mtime??0); }); - // render - els.tree.innerHTML = filtered.map(f => { + els.tree.innerHTML = filtered.map(f=>{ const d = new Date(f.mtime || Date.now()); const meta = `${d.toISOString().slice(0,10)} • ${f.name}`; - return ` - -
${escapeHtml(f.title)}
-
${meta}
-
- `; + return ` +
${esc(f.title)}
+
${meta}
+
`; }).join(""); - // close overlay on mobile after click - els.tree.addEventListener("click", evt => { - const link = evt.target.closest("a[data-path]"); - if (!link) return; + // close overlay on mobile when a link is clicked + els.tree.addEventListener("click", (evt)=>{ + if (!evt.target.closest("a[data-path]")) return; if (!window.matchMedia("(min-width:1025px)").matches){ els.body.classList.remove("sidebar-open"); state.sidebarOpen = false; - els.navToggle.setAttribute("aria-expanded", "false"); } }, { once:true }); } -/* ---------- Routing & rendering ---------- */ - +/* ---------- Routing & Rendering ---------- */ function onRoute(){ - const raw = location.hash || "#/"; - const [, path = "/"] = raw.split("#="); - const decoded = decodeURIComponent(path); - - els.routeHint.textContent = decoded === "/" ? "" : decoded; - - if (decoded === "/" || decoded === "") { - els.viewer.innerHTML = ` -
-

The Fold Within

-

Select a note on the left.

-
`; + const hash = location.hash || "#/"; + // Handle section routes like #/fieldnotes + const sectionMatch = hash.match(/^#\/(essays|fieldnotes|posts)\/?$/i); + if (sectionMatch){ + state.section = sectionMatch[1].toLowerCase(); + els.filterSection.value = state.section; + renderTree(); + els.viewer.innerHTML = `

${cap(state.section)}

Select a note on the left.

`; return; } - // security: lock to /public files only - if (decoded.includes("..")) { - els.viewer.textContent = "Invalid path."; + const [, rawPath=""] = hash.split("#="); + const rel = decodeURIComponent(rawPath); + + if (!rel){ + els.viewer.innerHTML = `

The Fold Within

Select a note on the left.

`; return; } - const ext = decoded.split(".").pop().toLowerCase(); - if (ext === "md") return renderMarkdown(decoded); - if (ext === "html") return renderHTML(decoded); - // default: try as md first, then html - return renderMarkdown(decoded).catch(() => renderHTML(decoded)); + if (rel.includes("..")){ els.viewer.textContent = "Invalid path."; return; } + + const ext = rel.split(".").pop().toLowerCase(); + if (ext==="md") return renderMarkdown(rel); + if (ext==="html") return renderHTML(rel); + // Try md then html as a convenience + renderMarkdown(rel).catch(()=>renderHTML(rel)); } -async function renderMarkdown(relPath){ - const res = await fetch("/" + relPath, { cache:"no-cache" }); +async function renderMarkdown(rel){ + const res = await fetch("/" + rel, { cache:"no-cache" }); if (!res.ok) throw new Error("not found"); const text = await res.text(); - const html = marked.parse(text, { mangle:false, headerIds:true }); - const safe = DOMPurify.sanitize(html, { - ALLOWED_TAGS: false, // allow default safe list - ALLOWED_ATTR: false - }); - + const html = window.marked.parse(text, { mangle:false, headerIds:true }); + const safe = window.DOMPurify.sanitize(html); els.viewer.innerHTML = safe; - // ensure the top of the article is visible on load without giant spacers + + // ensure we start at top; no reserved phantom space els.viewer.scrollIntoView({ block:"start", behavior:"instant" }); } -async function renderHTML(relPath){ - // Use an iframe for full HTML notes, auto-height to remove any blank space +async function renderHTML(rel){ const iframe = document.createElement("iframe"); - iframe.setAttribute("sandbox", "allow-same-origin allow-scripts allow-forms"); + iframe.setAttribute("sandbox","allow-same-origin allow-scripts allow-forms"); iframe.style.width = "100%"; iframe.style.border = "0"; iframe.loading = "eager"; - // Clear, mount, then size after load els.viewer.innerHTML = ""; els.viewer.appendChild(iframe); - iframe.src = "/" + relPath; + iframe.src = "/" + rel; - const sizeIframe = () => { - try { - const doc = iframe.contentDocument || iframe.contentWindow.document; - const h = Math.max( - doc.body.scrollHeight, - doc.documentElement.scrollHeight - ); + const size = ()=>{ + try{ + const d = iframe.contentDocument || iframe.contentWindow.document; + const h = Math.max(d.body.scrollHeight, d.documentElement.scrollHeight); iframe.style.height = h + "px"; - } catch { /* cross-origin shouldn't happen here */ } + }catch{} }; - - iframe.addEventListener("load", () => { - sizeIframe(); - // resize observer for dynamic html (images etc.) - try { - const ro = new ResizeObserver(sizeIframe); + iframe.addEventListener("load", ()=>{ + size(); + try{ + const ro = new ResizeObserver(size); ro.observe(iframe.contentDocument.documentElement); - } catch { /* not critical */ } - // also a delayed pass for images/fonts - setTimeout(sizeIframe, 250); - setTimeout(sizeIframe, 800); + }catch{} + setTimeout(size, 250); + setTimeout(size, 800); }); } -/* ---------- Helpers ---------- */ -function capitalize(s){ return s.charAt(0).toUpperCase() + s.slice(1) } -function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])) } \ No newline at end of file +/* ---------- Utils ---------- */ +const cap = s => s.charAt(0).toUpperCase() + s.slice(1); +const esc = s => s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); \ No newline at end of file