thefoldwithin-earth/public/app.js

233 lines
7.1 KiB
JavaScript
Raw Normal View History

2025-11-08 14:39:26 -06:00
/* global marked, DOMPurify */
2025-11-08 14:55:16 -06:00
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => [...r.querySelectorAll(s)];
2025-11-08 14:39:26 -06:00
const els = {
body: document.body,
sidebar: $("#sidebar"),
content: $("#content"),
viewer: $("#viewer"),
tree: $("#tree"),
navToggle: $("#navToggle"),
filterSection: $("#filterSection"),
sortOrder: $("#sortOrder"),
searchBox: $("#searchBox"),
};
2025-11-08 14:55:16 -06:00
const state = {
index: null,
section: "all",
sort: "newest",
q: "",
sidebarOpen: false,
desktopCollapsed: false
};
2025-11-08 14:39:26 -06:00
init();
async function init(){
2025-11-08 14:55:16 -06:00
// ensure libs present before any rendering
const ok = await waitForLibs(3000);
if (!ok){
els.viewer.innerHTML = `<p style="color:#f66">Markdown renderer failed to load. Check /lib/marked.min.js and /lib/purify.min.js.</p>`;
return;
2025-11-08 14:39:26 -06:00
}
2025-11-08 14:55:16 -06:00
// restore desktop collapse
try { state.desktopCollapsed = localStorage.getItem("desktopCollapsed")==="1"; } catch {}
2025-11-08 14:39:26 -06:00
2025-11-08 14:55:16 -06:00
wireToggles();
onResizeMode();
2025-11-08 14:39:26 -06:00
2025-11-08 14:55:16 -06:00
// load index
try{
const res = await fetch("/index.json", { cache:"no-cache" });
if (!res.ok) throw new Error(res.status);
2025-11-08 14:39:26 -06:00
state.index = await res.json();
2025-11-08 14:55:16 -06:00
}catch(e){
console.error("index.json load failed", e);
2025-11-08 14:39:26 -06:00
els.viewer.innerHTML = `<p style="color:#f66">Could not load index.json</p>`;
return;
}
buildFilters();
renderTree();
2025-11-08 14:55:16 -06:00
window.addEventListener("hashchange", onRoute);
2025-11-08 14:39:26 -06:00
onRoute();
}
2025-11-08 14:55:16 -06:00
/* ---------- 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);
})();
});
}
2025-11-08 14:39:26 -06:00
/* ---------- UI wiring ---------- */
2025-11-08 14:55:16 -06:00
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);
}
2025-11-08 14:39:26 -06:00
function onResizeMode(){
const desktop = window.matchMedia("(min-width:1025px)").matches;
2025-11-08 14:55:16 -06:00
if (desktop){
2025-11-08 14:39:26 -06:00
els.body.classList.remove("sidebar-open");
els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed);
} else {
els.body.classList.remove("sidebar-collapsed");
if (!state.sidebarOpen) els.body.classList.remove("sidebar-open");
}
}
function buildFilters(){
2025-11-08 14:55:16 -06:00
const sections = ["all", ...(state.index?.sections ?? [])];
els.filterSection.innerHTML = sections.map(s=>`<option value="${s}">${cap(s)}</option>`).join("");
2025-11-08 14:39:26 -06:00
els.filterSection.value = state.section;
2025-11-08 14:55:16 -06:00
els.filterSection.addEventListener("change", e=>{
2025-11-08 14:39:26 -06:00
state.section = e.target.value;
renderTree();
2025-11-08 12:59:27 -06:00
});
2025-11-08 14:39:26 -06:00
els.sortOrder.value = state.sort;
2025-11-08 14:55:16 -06:00
els.sortOrder.addEventListener("change", e=>{
2025-11-08 14:39:26 -06:00
state.sort = e.target.value;
renderTree();
2025-11-08 14:17:27 -06:00
});
2025-11-08 12:59:27 -06:00
2025-11-08 14:55:16 -06:00
els.searchBox.addEventListener("input", e=>{
2025-11-08 14:39:26 -06:00
state.q = e.target.value.trim().toLowerCase();
renderTree();
});
}
2025-11-08 14:17:27 -06:00
2025-11-08 14:55:16 -06:00
/* ---------- Tree ---------- */
2025-11-08 14:39:26 -06:00
function renderTree(){
if (!state.index) return;
const items = state.index.flat.slice();
2025-11-08 14:17:27 -06:00
2025-11-08 14:55:16 -06:00
const filtered = items.filter(f=>{
const inSection = state.section==="all" || f.path.startsWith(state.section + "/");
2025-11-08 14:39:26 -06:00
const inQuery = !state.q || f.title.toLowerCase().includes(state.q) || f.name.toLowerCase().includes(state.q);
return inSection && inQuery;
});
2025-11-08 14:55:16 -06:00
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);
2025-11-08 14:39:26 -06:00
});
2025-11-08 14:55:16 -06:00
els.tree.innerHTML = filtered.map(f=>{
2025-11-08 14:39:26 -06:00
const d = new Date(f.mtime || Date.now());
const meta = `${d.toISOString().slice(0,10)}${f.name}`;
2025-11-08 14:55:16 -06:00
return `<a href="#=${encodeURIComponent(f.path)}" data-path="${f.path}">
<div class="title">${esc(f.title)}</div>
<div class="meta">${meta}</div>
</a>`;
2025-11-08 14:39:26 -06:00
}).join("");
2025-11-08 14:55:16 -06:00
// close overlay on mobile when a link is clicked
els.tree.addEventListener("click", (evt)=>{
if (!evt.target.closest("a[data-path]")) return;
2025-11-08 14:39:26 -06:00
if (!window.matchMedia("(min-width:1025px)").matches){
els.body.classList.remove("sidebar-open");
state.sidebarOpen = false;
2025-11-08 12:12:47 -06:00
}
2025-11-08 14:39:26 -06:00
}, { once:true });
}
2025-11-08 14:17:27 -06:00
2025-11-08 14:55:16 -06:00
/* ---------- Routing & Rendering ---------- */
2025-11-08 14:39:26 -06:00
function onRoute(){
2025-11-08 14:55:16 -06:00
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 = `<div class="empty"><h1>${cap(state.section)}</h1><p>Select a note on the left.</p></div>`;
2025-11-08 14:39:26 -06:00
return;
2025-11-08 12:12:47 -06:00
}
2025-11-08 12:59:27 -06:00
2025-11-08 14:55:16 -06:00
const [, rawPath=""] = hash.split("#=");
const rel = decodeURIComponent(rawPath);
if (!rel){
els.viewer.innerHTML = `<div class="empty"><h1>The Fold Within</h1><p>Select a note on the left.</p></div>`;
2025-11-08 14:39:26 -06:00
return;
2025-11-08 14:17:27 -06:00
}
2025-11-08 13:06:29 -06:00
2025-11-08 14:55:16 -06:00
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));
2025-11-08 14:39:26 -06:00
}
2025-11-08 14:55:16 -06:00
async function renderMarkdown(rel){
const res = await fetch("/" + rel, { cache:"no-cache" });
2025-11-08 14:39:26 -06:00
if (!res.ok) throw new Error("not found");
const text = await res.text();
2025-11-08 14:55:16 -06:00
const html = window.marked.parse(text, { mangle:false, headerIds:true });
const safe = window.DOMPurify.sanitize(html);
2025-11-08 14:39:26 -06:00
els.viewer.innerHTML = safe;
2025-11-08 14:55:16 -06:00
// ensure we start at top; no reserved phantom space
2025-11-08 14:39:26 -06:00
els.viewer.scrollIntoView({ block:"start", behavior:"instant" });
}
2025-11-08 14:55:16 -06:00
async function renderHTML(rel){
2025-11-08 14:39:26 -06:00
const iframe = document.createElement("iframe");
2025-11-08 14:55:16 -06:00
iframe.setAttribute("sandbox","allow-same-origin allow-scripts allow-forms");
2025-11-08 14:39:26 -06:00
iframe.style.width = "100%";
iframe.style.border = "0";
iframe.loading = "eager";
els.viewer.innerHTML = "";
els.viewer.appendChild(iframe);
2025-11-08 14:55:16 -06:00
iframe.src = "/" + rel;
const size = ()=>{
try{
const d = iframe.contentDocument || iframe.contentWindow.document;
const h = Math.max(d.body.scrollHeight, d.documentElement.scrollHeight);
2025-11-08 14:39:26 -06:00
iframe.style.height = h + "px";
2025-11-08 14:55:16 -06:00
}catch{}
2025-11-08 14:39:26 -06:00
};
2025-11-08 14:55:16 -06:00
iframe.addEventListener("load", ()=>{
size();
try{
const ro = new ResizeObserver(size);
2025-11-08 14:39:26 -06:00
ro.observe(iframe.contentDocument.documentElement);
2025-11-08 14:55:16 -06:00
}catch{}
setTimeout(size, 250);
setTimeout(size, 800);
2025-11-08 14:39:26 -06:00
});
}
2025-11-08 14:55:16 -06:00
/* ---------- Utils ---------- */
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const esc = s => s.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));