diff --git a/public/app.js b/public/app.js index a549834..cc2a85c 100755 --- a/public/app.js +++ b/public/app.js @@ -1,154 +1,252 @@ -/* ============================================================ - The Fold Within — app.js v2.6.2 - ============================================================ */ +/* global marked, DOMPurify */ +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; -document.addEventListener("DOMContentLoaded", () => { - const sidebar = document.querySelector(".sidebar"); - const overlay = document.querySelector(".overlay"); - const navToggle = document.getElementById("navToggle"); - const content = document.querySelector(".content"); - const mdView = document.getElementById("mdView"); - const htmlView = document.getElementById("htmlView"); +const state = { + index: null, + sidebarOpen: false, // mobile overlay (<=1024) + desktopCollapsed: false, // desktop collapse + sort: "newest", + section: "all", + q: "" +}; - let currentPath = ""; - let usedFallback = false; +const els = { + body: document.body, + sidebar: $("#sidebar"), + content: $("#content"), + viewer: $("#viewer"), + tree: $("#tree"), + routeHint: $("#routeHint"), + navToggle: $("#navToggle"), + filterSection: $("#filterSection"), + sortOrder: $("#sortOrder"), + searchBox: $("#searchBox"), +}; - /* ============================================================ - Sidebar Toggle (Desktop + Mobile) - ============================================================ */ - navToggle.addEventListener("click", () => { - const isDesktop = window.innerWidth >= 900; - if (isDesktop) { - sidebar.classList.toggle("collapsed"); - content.classList.toggle("full"); - } else { - sidebar.classList.toggle("open"); - overlay.classList.toggle("active"); - } - }); +init(); - overlay.addEventListener("click", () => { - sidebar.classList.remove("open"); - overlay.classList.remove("active"); - }); +async function init(){ + // restore preferences + try { + state.desktopCollapsed = localStorage.getItem("desktopCollapsed") === "1"; + } catch {} - /* ============================================================ - Load External Libraries (Marked + DOMPurify) - ============================================================ */ - async function ensureLibsReady(timeoutMs = 4000) { - const start = Date.now(); - while ( - (!window.marked || !window.DOMPurify) && - Date.now() - start < timeoutMs - ) { - await new Promise((r) => setTimeout(r, 100)); - } - if (!window.marked || !window.DOMPurify) { - console.warn("Markdown libraries not loaded — fallback to plain text."); - usedFallback = true; - } else usedFallback = false; + // 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 } - /* ============================================================ - File Loading + Rendering - ============================================================ */ - async function loadFile(path) { - if (!path) return; - currentPath = path; + els.navToggle.addEventListener("click", toggleSidebar); + window.addEventListener("resize", onResizeMode); + window.addEventListener("hashchange", onRoute); + onResizeMode(); // set initial open/closed class for mobile/desktop + + // Fetch index + try { + const res = await fetch("/index.json", { cache: "no-cache" }); + if (!res.ok) throw new Error(res.status + " " + res.statusText); + state.index = await res.json(); + } catch (e) { + console.error("Failed to load index.json", e); + els.viewer.innerHTML = `

Could not load index.json

`; + return; + } + + // Build filters + buildFilters(); + + // Render tree + renderTree(); + + // Route initial + onRoute(); +} + +/* ---------- UI wiring ---------- */ + +function onResizeMode(){ + const desktop = window.matchMedia("(min-width:1025px)").matches; + + if (desktop) { + // Desktop: overlay classes off; use collapsed flag to shift layout + 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(""); + els.filterSection.value = state.section; + + els.filterSection.addEventListener("change", e => { + state.section = e.target.value; + renderTree(); + }); + + els.sortOrder.value = state.sort; + els.sortOrder.addEventListener("change", e => { + state.sort = e.target.value; + renderTree(); + }); + + els.searchBox.addEventListener("input", e => { + state.q = e.target.value.trim().toLowerCase(); + renderTree(); + }); +} + +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 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 + }); + + // render + 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}
+
+ `; + }).join(""); + + // close overlay on mobile after click + els.tree.addEventListener("click", evt => { + const link = evt.target.closest("a[data-path]"); + if (!link) 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 ---------- */ + +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.

+
`; + return; + } + + // security: lock to /public files only + if (decoded.includes("..")) { + els.viewer.textContent = "Invalid path."; + 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)); +} + +async function renderMarkdown(relPath){ + const res = await fetch("/" + relPath, { 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 + }); + + els.viewer.innerHTML = safe; + // ensure the top of the article is visible on load without giant spacers + 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 + const iframe = document.createElement("iframe"); + 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; + + const sizeIframe = () => { try { - const res = await fetch(path); - if (!res.ok) throw new Error(`Path not found: ${path}`); + const doc = iframe.contentDocument || iframe.contentWindow.document; + const h = Math.max( + doc.body.scrollHeight, + doc.documentElement.scrollHeight + ); + iframe.style.height = h + "px"; + } catch { /* cross-origin shouldn't happen here */ } + }; - const ext = path.split(".").pop().toLowerCase(); - const text = await res.text(); - - if (ext === "md" || ext === "markdown") { - await ensureLibsReady(); - renderMarkdown(text); - } else if (ext === "html" || ext === "htm") { - renderHTML(text); - } else { - mdView.innerHTML = `
Unsupported file type: ${ext}
`; - htmlView.innerHTML = ""; - } - } catch (err) { - console.error(err); - mdView.innerHTML = `
${err.message}
`; - htmlView.innerHTML = ""; - } - } - - /* ============================================================ - Renderers - ============================================================ */ - function renderMarkdown(text) { - const safe = window.DOMPurify - ? DOMPurify.sanitize(window.marked.parse(text)) - : text - .replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); - mdView.innerHTML = usedFallback - ? `
Markdown fallback to plain text (libs failed). Check console.
${safe}
` - : safe; - fadeIn(mdView); - } - - function renderHTML(text) { - htmlView.srcdoc = text; - fadeIn(htmlView); - } - - /* ============================================================ - Tree Navigation (dynamic) - ============================================================ */ - document.querySelectorAll(".file").forEach((fileEl) => { - fileEl.addEventListener("click", () => { - const path = fileEl.dataset.path; - loadFile(path); - - // highlight active - document - .querySelectorAll(".file.active") - .forEach((el) => el.classList.remove("active")); - fileEl.classList.add("active"); - - // auto-collapse mobile - if (window.innerWidth < 900) { - sidebar.classList.remove("open"); - overlay.classList.remove("active"); - } - }); + iframe.addEventListener("load", () => { + sizeIframe(); + // resize observer for dynamic html (images etc.) + try { + const ro = new ResizeObserver(sizeIframe); + ro.observe(iframe.contentDocument.documentElement); + } catch { /* not critical */ } + // also a delayed pass for images/fonts + setTimeout(sizeIframe, 250); + setTimeout(sizeIframe, 800); }); +} - /* ============================================================ - Fade Animation Helper - ============================================================ */ - function fadeIn(el) { - if (!el) return; - el.classList.remove("fade-in"); - void el.offsetWidth; // reflow - el.classList.add("fade-in"); - } - - /* ============================================================ - Hash-Based Routing (supports #=posts/file.md) - ============================================================ */ - function handleHashChange() { - const hash = window.location.hash.replace(/^#=+/, ""); - if (hash && hash !== currentPath) loadFile(hash); - } - - window.addEventListener("hashchange", handleHashChange); - handleHashChange(); - - /* ============================================================ - Keyboard Shortcuts (optional) - ============================================================ */ - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - sidebar.classList.remove("open", "collapsed"); - content.classList.remove("full"); - overlay.classList.remove("active"); - } - }); -}); \ No newline at end of file +/* ---------- 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