diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..addc49f --- /dev/null +++ b/public/app.js @@ -0,0 +1,192 @@ +/* app.js (v2.0.0) + * - loads /index.json (generated at build) + * - builds collapsible tree (left) + * - renders HTML in sandboxed iframe OR Markdown via marked+DOMPurify + * - default sort: date (new→old). toggle to alpha. + * - deep links: #=/posts/foo.md + */ + +let INDEX = null; +let CURRENT_PATH = null; +let PATH_TO_EL = new Map(); + +const treeEl = document.getElementById("tree"); +const sortSel = document.getElementById("sort"); +const filterSel = document.getElementById("filter"); +const iframe = document.getElementById("htmlFrame"); +const mdBox = document.getElementById("mdContainer"); +const metaEl = document.getElementById("meta"); + +const ICONS = { + ".md": "📝", + ".html": "🧩", + "dir": "🗂️" +}; + +function icon(ext){ return ICONS[ext] || "📄"; } + +function humanDate(ms){ + const d = new Date(ms || 0); + if (!ms) return ""; + return d.toISOString().slice(0,10); +} + +function applySort(list, mode){ + const arr = list.slice(); + if (mode === "alpha") { + arr.sort((a,b)=> a.title.localeCompare(b.title)); + } else { + arr.sort((a,b)=> (b.mtime||0)-(a.mtime||0)); // new → old + } + return arr; +} + +function filterFlat(list, filter){ + if (filter === "pinned") return list.filter(x=>x.pinned); + if (filter === "posts") return list.filter(x=>!x.pinned); + return list; +} + +function filterTree(node, allowedSet){ + if (node.type === "file") return allowedSet.has(node.path) ? node : null; + const kids = []; + for (const c of node.children||[]){ + const f = filterTree(c, allowedSet); + if (f) kids.push(f); + } + return { ...node, children: kids }; +} + +function buildNode(node){ + if (node.type === "dir"){ + const wrap = document.createElement("div"); + wrap.className = "tree-node dir"; + wrap.setAttribute("role","treeitem"); + wrap.setAttribute("aria-expanded","false"); + + const label = document.createElement("div"); + label.className = "dir-label"; + label.innerHTML = `${icon("dir")}${node.name}`; + label.addEventListener("click", ()=>{ + const open = wrap.classList.toggle("open"); + wrap.setAttribute("aria-expanded", open?"true":"false"); + }); + wrap.appendChild(label); + + const children = document.createElement("div"); + children.className = "children"; + for (const c of node.children || []){ + children.appendChild(buildNode(c)); + } + wrap.appendChild(children); + return wrap; + } else { + const a = document.createElement("a"); + a.className = "file"; + a.setAttribute("role","treeitem"); + a.href = `#=${node.path}`; + a.innerHTML = `${node.pinned ? 'PIN' : ''}${icon(node.ext)} ${node.title} ${humanDate(node.mtime)} · ${node.name}`; + a.addEventListener("click", (e)=>{ + e.preventDefault(); + openPath(node.path); + }); + PATH_TO_EL.set(node.path, a); + return a; + } +} + +function setActive(path){ + document.querySelectorAll(".file.active").forEach(el=>el.classList.remove("active")); + const el = PATH_TO_EL.get(path); + if (el){ + el.classList.add("active"); + // ensure ancestors open + let p = el.parentElement; + while (p && p !== treeEl){ + if (p.classList.contains("dir")){ + p.classList.add("open"); + p.setAttribute("aria-expanded","true"); + } + p = p.parentElement; + } + el.scrollIntoView({ block:"nearest", behavior:"smooth" }); + } +} + +function rebuildTree(){ + PATH_TO_EL.clear(); + treeEl.innerHTML = ""; + // compute filtered/sorted flat set + const sorted = applySort(filterFlat(INDEX.flat, filterSel.value), sortSel.value); + const allowed = new Set(sorted.map(x=>x.path)); + const filteredTree = filterTree(INDEX.tree, allowed); + treeEl.appendChild(buildNode(filteredTree)); + // expand top-level + treeEl.querySelectorAll(".dir").forEach(d=>d.classList.add("open")); +} + +async function renderMarkdown(path){ + iframe.hidden = true; + mdBox.hidden = false; + + const res = await fetch(path, { cache: "no-cache" }); + if (!res.ok) throw new Error(`fetch ${path} ${res.status}`); + const txt = await res.text(); + // marked is global when CDN loads; fallback to plain text if missing + const rawHtml = (window.marked ? window.marked.parse(txt) : `
${txt.replace(/[&<>]/g, s=>({ "&":"&","<":"<",">":">" }[s]))}
`); + const safe = (window.DOMPurify ? window.DOMPurify.sanitize(rawHtml) : rawHtml); + mdBox.innerHTML = safe; +} + +async function renderHTML(path){ + mdBox.hidden = true; + iframe.hidden = false; + iframe.src = path; +} + +async function openPath(path){ + if (!path || path === CURRENT_PATH) return; + CURRENT_PATH = path; + if (location.hash !== `#=${path}`) history.pushState(null,"",`#=${path}`); + + const entry = INDEX.flat.find(x=>x.path === path); + if (!entry) return; + + metaEl.textContent = `${entry.pinned ? "Pinned • " : ""}${humanDate(entry.mtime)} • ${entry.path}`; + setActive(path); + + if (entry.ext === ".md") await renderMarkdown(path); + else await renderHTML(path); +} + +async function boot(){ + try{ + const res = await fetch("/index.json", { cache: "no-cache" }); + INDEX = await res.json(); + } catch (e){ + treeEl.innerHTML = "

index.json missing. run the build.

"; + return; + } + + sortSel.addEventListener("change", rebuildTree); + filterSel.addEventListener("change", rebuildTree); + + rebuildTree(); + + // choose initial route + const hashPath = location.hash.startsWith("#=") ? location.hash.slice(2) : null; + if (hashPath) { + openPath(hashPath); + } else { + // default: newest among current filter + const sorted = applySort(filterFlat(INDEX.flat, filterSel.value), "date"); + if (sorted.length) openPath(sorted[0].path); + } + + window.addEventListener("popstate", ()=>{ + const hp = location.hash.startsWith("#=") ? location.hash.slice(2) : null; + if (hp && hp !== CURRENT_PATH) openPath(hp); + }); +} + +boot(); \ No newline at end of file