Update app.js

This commit is contained in:
Mark Randall Havens △ The Empathic Technologist ⟁ Doctor Who 42 2025-11-08 14:55:16 -06:00 committed by GitHub
parent 653a6fc7a3
commit 8383931cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,15 +1,6 @@
/* global marked, DOMPurify */ /* global marked, DOMPurify */
const $ = (sel, root = document) => root.querySelector(sel); const $ = (s, r=document) => r.querySelector(s);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const $$ = (s, r=document) => [...r.querySelectorAll(s)];
const state = {
index: null,
sidebarOpen: false, // mobile overlay (<=1024)
desktopCollapsed: false, // desktop collapse
sort: "newest",
section: "all",
q: ""
};
const els = { const els = {
body: document.body, body: document.body,
@ -17,236 +8,226 @@ const els = {
content: $("#content"), content: $("#content"),
viewer: $("#viewer"), viewer: $("#viewer"),
tree: $("#tree"), tree: $("#tree"),
routeHint: $("#routeHint"),
navToggle: $("#navToggle"), navToggle: $("#navToggle"),
filterSection: $("#filterSection"), filterSection: $("#filterSection"),
sortOrder: $("#sortOrder"), sortOrder: $("#sortOrder"),
searchBox: $("#searchBox"), searchBox: $("#searchBox"),
}; };
const state = {
index: null,
section: "all",
sort: "newest",
q: "",
sidebarOpen: false,
desktopCollapsed: false
};
init(); init();
async function init(){ async function init(){
// restore preferences // ensure libs present before any rendering
try { const ok = await waitForLibs(3000);
state.desktopCollapsed = localStorage.getItem("desktopCollapsed") === "1"; if (!ok){
} catch {} els.viewer.innerHTML = `<p style="color:#f66">Markdown renderer failed to load. Check /lib/marked.min.js and /lib/purify.min.js.</p>`;
return;
// 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
} }
els.navToggle.addEventListener("click", toggleSidebar); // restore desktop collapse
try { state.desktopCollapsed = localStorage.getItem("desktopCollapsed")==="1"; } catch {}
window.addEventListener("resize", onResizeMode); wireToggles();
window.addEventListener("hashchange", onRoute); onResizeMode();
onResizeMode(); // set initial open/closed class for mobile/desktop
// Fetch index // load index
try { try{
const res = await fetch("/index.json", { cache: "no-cache" }); const res = await fetch("/index.json", { cache:"no-cache" });
if (!res.ok) throw new Error(res.status + " " + res.statusText); if (!res.ok) throw new Error(res.status);
state.index = await res.json(); state.index = await res.json();
} catch (e) { }catch(e){
console.error("Failed to load index.json", e); console.error("index.json load failed", e);
els.viewer.innerHTML = `<p style="color:#f66">Could not load index.json</p>`; els.viewer.innerHTML = `<p style="color:#f66">Could not load index.json</p>`;
return; return;
} }
// Build filters
buildFilters(); buildFilters();
// Render tree
renderTree(); renderTree();
// Route initial window.addEventListener("hashchange", onRoute);
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 ---------- */ /* ---------- 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(){ function onResizeMode(){
const desktop = window.matchMedia("(min-width:1025px)").matches; const desktop = window.matchMedia("(min-width:1025px)").matches;
if (desktop){
if (desktop) {
// Desktop: overlay classes off; use collapsed flag to shift layout
els.body.classList.remove("sidebar-open"); els.body.classList.remove("sidebar-open");
els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed); els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed);
els.navToggle.setAttribute("aria-expanded", (!state.desktopCollapsed).toString());
} else { } else {
// Mobile: collapsed flag irrelevant; use overlay class instead
els.body.classList.remove("sidebar-collapsed"); els.body.classList.remove("sidebar-collapsed");
if (!state.sidebarOpen) els.body.classList.remove("sidebar-open"); 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(){ function buildFilters(){
const sections = ["all", ...state.index.sections]; const sections = ["all", ...(state.index?.sections ?? [])];
els.filterSection.innerHTML = sections.map(s => els.filterSection.innerHTML = sections.map(s=>`<option value="${s}">${cap(s)}</option>`).join("");
`<option value="${s}">${capitalize(s)}</option>`
).join("");
els.filterSection.value = state.section; els.filterSection.value = state.section;
els.filterSection.addEventListener("change", e => { els.filterSection.addEventListener("change", e=>{
state.section = e.target.value; state.section = e.target.value;
renderTree(); renderTree();
}); });
els.sortOrder.value = state.sort; els.sortOrder.value = state.sort;
els.sortOrder.addEventListener("change", e => { els.sortOrder.addEventListener("change", e=>{
state.sort = e.target.value; state.sort = e.target.value;
renderTree(); renderTree();
}); });
els.searchBox.addEventListener("input", e => { els.searchBox.addEventListener("input", e=>{
state.q = e.target.value.trim().toLowerCase(); state.q = e.target.value.trim().toLowerCase();
renderTree(); renderTree();
}); });
} }
/* ---------- Tree ---------- */
function renderTree(){ function renderTree(){
if (!state.index) return; if (!state.index) return;
const items = state.index.flat.slice(); const items = state.index.flat.slice();
// filter const filtered = items.filter(f=>{
const filtered = items.filter(f => { const inSection = state.section==="all" || f.path.startsWith(state.section + "/");
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); const inQuery = !state.q || f.title.toLowerCase().includes(state.q) || f.name.toLowerCase().includes(state.q);
return inSection && inQuery; return inSection && inQuery;
}); });
// sort filtered.sort((a,b)=>{
filtered.sort((a,b) => { if (state.sort==="title") return a.title.localeCompare(b.title, undefined, {sensitivity:"base"});
if (state.sort === "title") return a.title.localeCompare(b.title, undefined, {sensitivity:"base"}); if (state.sort==="oldest") return (a.mtime??0) - (b.mtime??0);
if (state.sort === "oldest") return (a.mtime ?? 0) - (b.mtime ?? 0); return (b.mtime??0) - (a.mtime??0);
return (b.mtime ?? 0) - (a.mtime ?? 0); // newest
}); });
// render els.tree.innerHTML = filtered.map(f=>{
els.tree.innerHTML = filtered.map(f => {
const d = new Date(f.mtime || Date.now()); const d = new Date(f.mtime || Date.now());
const meta = `${d.toISOString().slice(0,10)}${f.name}`; const meta = `${d.toISOString().slice(0,10)}${f.name}`;
return ` return `<a href="#=${encodeURIComponent(f.path)}" data-path="${f.path}">
<a href="#=${encodeURIComponent(f.path)}" data-path="${f.path}"> <div class="title">${esc(f.title)}</div>
<div class="title">${escapeHtml(f.title)}</div> <div class="meta">${meta}</div>
<div class="meta">${meta}</div> </a>`;
</a>
`;
}).join(""); }).join("");
// close overlay on mobile after click // close overlay on mobile when a link is clicked
els.tree.addEventListener("click", evt => { els.tree.addEventListener("click", (evt)=>{
const link = evt.target.closest("a[data-path]"); if (!evt.target.closest("a[data-path]")) return;
if (!link) return;
if (!window.matchMedia("(min-width:1025px)").matches){ if (!window.matchMedia("(min-width:1025px)").matches){
els.body.classList.remove("sidebar-open"); els.body.classList.remove("sidebar-open");
state.sidebarOpen = false; state.sidebarOpen = false;
els.navToggle.setAttribute("aria-expanded", "false");
} }
}, { once:true }); }, { once:true });
} }
/* ---------- Routing & rendering ---------- */ /* ---------- Routing & Rendering ---------- */
function onRoute(){ function onRoute(){
const raw = location.hash || "#/"; const hash = location.hash || "#/";
const [, path = "/"] = raw.split("#="); // Handle section routes like #/fieldnotes
const decoded = decodeURIComponent(path); const sectionMatch = hash.match(/^#\/(essays|fieldnotes|posts)\/?$/i);
if (sectionMatch){
els.routeHint.textContent = decoded === "/" ? "" : decoded; state.section = sectionMatch[1].toLowerCase();
els.filterSection.value = state.section;
if (decoded === "/" || decoded === "") { renderTree();
els.viewer.innerHTML = ` els.viewer.innerHTML = `<div class="empty"><h1>${cap(state.section)}</h1><p>Select a note on the left.</p></div>`;
<div class="empty">
<h1>The Fold Within</h1>
<p>Select a note on the left.</p>
</div>`;
return; return;
} }
// security: lock to /public files only const [, rawPath=""] = hash.split("#=");
if (decoded.includes("..")) { const rel = decodeURIComponent(rawPath);
els.viewer.textContent = "Invalid path.";
if (!rel){
els.viewer.innerHTML = `<div class="empty"><h1>The Fold Within</h1><p>Select a note on the left.</p></div>`;
return; return;
} }
const ext = decoded.split(".").pop().toLowerCase(); if (rel.includes("..")){ els.viewer.textContent = "Invalid path."; return; }
if (ext === "md") return renderMarkdown(decoded);
if (ext === "html") return renderHTML(decoded); const ext = rel.split(".").pop().toLowerCase();
// default: try as md first, then html if (ext==="md") return renderMarkdown(rel);
return renderMarkdown(decoded).catch(() => renderHTML(decoded)); if (ext==="html") return renderHTML(rel);
// Try md then html as a convenience
renderMarkdown(rel).catch(()=>renderHTML(rel));
} }
async function renderMarkdown(relPath){ async function renderMarkdown(rel){
const res = await fetch("/" + relPath, { cache:"no-cache" }); const res = await fetch("/" + rel, { cache:"no-cache" });
if (!res.ok) throw new Error("not found"); if (!res.ok) throw new Error("not found");
const text = await res.text(); const text = await res.text();
const html = marked.parse(text, { mangle:false, headerIds:true }); const html = window.marked.parse(text, { mangle:false, headerIds:true });
const safe = DOMPurify.sanitize(html, { const safe = window.DOMPurify.sanitize(html);
ALLOWED_TAGS: false, // allow default safe list
ALLOWED_ATTR: false
});
els.viewer.innerHTML = safe; 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" }); els.viewer.scrollIntoView({ block:"start", behavior:"instant" });
} }
async function renderHTML(relPath){ async function renderHTML(rel){
// Use an iframe for full HTML notes, auto-height to remove any blank space
const iframe = document.createElement("iframe"); 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.width = "100%";
iframe.style.border = "0"; iframe.style.border = "0";
iframe.loading = "eager"; iframe.loading = "eager";
// Clear, mount, then size after load
els.viewer.innerHTML = ""; els.viewer.innerHTML = "";
els.viewer.appendChild(iframe); els.viewer.appendChild(iframe);
iframe.src = "/" + relPath; iframe.src = "/" + rel;
const sizeIframe = () => { const size = ()=>{
try { try{
const doc = iframe.contentDocument || iframe.contentWindow.document; const d = iframe.contentDocument || iframe.contentWindow.document;
const h = Math.max( const h = Math.max(d.body.scrollHeight, d.documentElement.scrollHeight);
doc.body.scrollHeight,
doc.documentElement.scrollHeight
);
iframe.style.height = h + "px"; iframe.style.height = h + "px";
} catch { /* cross-origin shouldn't happen here */ } }catch{}
}; };
iframe.addEventListener("load", ()=>{
iframe.addEventListener("load", () => { size();
sizeIframe(); try{
// resize observer for dynamic html (images etc.) const ro = new ResizeObserver(size);
try {
const ro = new ResizeObserver(sizeIframe);
ro.observe(iframe.contentDocument.documentElement); ro.observe(iframe.contentDocument.documentElement);
} catch { /* not critical */ } }catch{}
// also a delayed pass for images/fonts setTimeout(size, 250);
setTimeout(sizeIframe, 250); setTimeout(size, 800);
setTimeout(sizeIframe, 800);
}); });
} }
/* ---------- Helpers ---------- */ /* ---------- Utils ---------- */
function capitalize(s){ return s.charAt(0).toUpperCase() + s.slice(1) } const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])) } const esc = s => s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));