Update app.js
This commit is contained in:
parent
79a281ebbf
commit
fdf1dfd7e2
1 changed files with 66 additions and 204 deletions
270
public/app.js
270
public/app.js
|
|
@ -1,243 +1,105 @@
|
||||||
/* global marked, DOMPurify */
|
|
||||||
const $ = (s, r=document) => r.querySelector(s);
|
|
||||||
const $$ = (s, r=document) => [...r.querySelectorAll(s)];
|
|
||||||
|
|
||||||
const els = {
|
const els = {
|
||||||
body: document.body,
|
body: document.body,
|
||||||
sidebar: $("#sidebar"),
|
menuBtn: document.getElementById("menuBtn"),
|
||||||
content: $("#content"),
|
sectionSelect: document.getElementById("sectionSelect"),
|
||||||
viewer: $("#viewer"),
|
sortSelect: document.getElementById("sortSelect"),
|
||||||
tree: $("#tree"),
|
searchBox: document.getElementById("searchBox"),
|
||||||
navToggle: $("#navToggle"),
|
postList: document.getElementById("postList"),
|
||||||
filterSection: $("#filterSection"),
|
viewer: document.getElementById("viewer"),
|
||||||
sortOrder: $("#sortOrder"),
|
content: document.getElementById("content")
|
||||||
searchBox: $("#searchBox"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = {
|
let indexData = null;
|
||||||
index: null,
|
let state = { section: "posts", sort: "newest", query: "", sidebarOpen: false };
|
||||||
section: "all",
|
|
||||||
sort: "newest",
|
|
||||||
q: "",
|
|
||||||
sidebarOpen: false,
|
|
||||||
desktopCollapsed: false
|
|
||||||
};
|
|
||||||
|
|
||||||
init();
|
|
||||||
|
|
||||||
async function init(){
|
async function init(){
|
||||||
const ok = await waitForLibs(3000);
|
indexData = await (await fetch("index.json")).json();
|
||||||
if (!ok){
|
populateSections();
|
||||||
els.viewer.innerHTML = `<p style="color:#f66">Markdown renderer failed to load. Check /lib/marked.min.js and /lib/purify.min.js.</p>`;
|
wireUI();
|
||||||
return;
|
renderList();
|
||||||
|
handleHash();
|
||||||
|
window.addEventListener("hashchange", handleHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
try { state.desktopCollapsed = localStorage.getItem("desktopCollapsed")==="1"; } catch {}
|
function populateSections(){
|
||||||
|
els.sectionSelect.innerHTML = "";
|
||||||
wireToggles();
|
indexData.sections.forEach(s=>{
|
||||||
onResizeMode();
|
const opt = document.createElement("option");
|
||||||
|
opt.value = s; opt.textContent = s;
|
||||||
try{
|
els.sectionSelect.appendChild(opt);
|
||||||
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("index.json load failed", e);
|
|
||||||
els.viewer.innerHTML = `<p style="color:#f66">Could not load index.json</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFilters();
|
|
||||||
renderTree();
|
|
||||||
|
|
||||||
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 wireUI(){
|
||||||
function wireToggles(){
|
els.menuBtn.addEventListener("click", ()=>{
|
||||||
els.navToggle.addEventListener("click", ()=>{
|
state.sidebarOpen = !state.sidebarOpen;
|
||||||
const desktop = window.matchMedia("(min-width:1025px)").matches;
|
document.body.classList.toggle("sidebar-open", state.sidebarOpen);
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
els.sectionSelect.addEventListener("change", ()=>renderList());
|
||||||
window.addEventListener("resize", onResizeMode);
|
els.sortSelect.addEventListener("change", ()=>renderList());
|
||||||
}
|
els.searchBox.addEventListener("input", ()=>renderList());
|
||||||
|
els.content.addEventListener("click", ()=>{
|
||||||
function onResizeMode(){
|
if (window.matchMedia("(max-width:1024px)").matches && document.body.classList.contains("sidebar-open")){
|
||||||
const desktop = window.matchMedia("(min-width:1025px)").matches;
|
document.body.classList.remove("sidebar-open");
|
||||||
if (desktop){
|
|
||||||
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(){
|
|
||||||
const sections = ["all", ...(state.index?.sections ?? [])];
|
|
||||||
els.filterSection.innerHTML = sections.map(s=>`<option value="${s}">${cap(s)}</option>`).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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Tree ---------- */
|
|
||||||
function renderTree(){
|
|
||||||
if (!state.index) return;
|
|
||||||
const items = state.index.flat.slice();
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
els.tree.innerHTML = filtered.map(f=>{
|
|
||||||
const d = new Date(f.mtime || Date.now());
|
|
||||||
const meta = `${d.toISOString().slice(0,10)} • ${f.name}`;
|
|
||||||
return `<a href="#=${encodeURIComponent(f.path)}" data-path="${f.path}">
|
|
||||||
<div class="title">${esc(f.title)}</div>
|
|
||||||
<div class="meta">${meta}</div>
|
|
||||||
</a>`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
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;
|
state.sidebarOpen = false;
|
||||||
}
|
}
|
||||||
}, { once:true });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Routing & Rendering ---------- */
|
function renderList(){
|
||||||
function onRoute(){
|
const section = els.sectionSelect.value;
|
||||||
const hash = location.hash || "#/";
|
const sort = els.sortSelect.value;
|
||||||
const sectionMatch = hash.match(/^#\/(essays|fieldnotes|posts)\/?$/i);
|
const query = els.searchBox.value.toLowerCase();
|
||||||
if (sectionMatch){
|
|
||||||
state.section = sectionMatch[1].toLowerCase();
|
let posts = indexData.flat.filter(p=>p.path.startsWith(section));
|
||||||
els.filterSection.value = state.section;
|
if (query) posts = posts.filter(p=>p.title.toLowerCase().includes(query));
|
||||||
renderTree();
|
posts.sort((a,b)=> sort==="newest"? b.mtime-a.mtime : a.mtime-b.mtime);
|
||||||
els.viewer.innerHTML = `<div class="empty"><h1>${cap(state.section)}</h1><p>Select a note on the left.</p></div>`;
|
|
||||||
return;
|
els.postList.innerHTML = "";
|
||||||
|
for (const p of posts){
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.innerHTML = `<a href="#/${p.path}">${p.title}</a><br><small>${new Date(p.mtime).toISOString().split("T")[0]}</small>`;
|
||||||
|
els.postList.appendChild(li);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, rawPath=""] = hash.split("#=");
|
async function handleHash(){
|
||||||
const rel = decodeURIComponent(rawPath);
|
const rel = location.hash.replace(/^#\//,"");
|
||||||
|
if (!rel) return renderDefault();
|
||||||
if (!rel){
|
const file = indexData.flat.find(f=>f.path===rel);
|
||||||
els.viewer.innerHTML = `<div class="empty"><h1>The Fold Within</h1><p>Select a note on the left.</p></div>`;
|
if (!file) return;
|
||||||
return;
|
file.ext===".md"? await renderMarkdown(file.path) : renderHTML(file.path);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
renderMarkdown(rel).catch(()=>renderHTML(rel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMarkdown(rel){
|
async function renderMarkdown(rel){
|
||||||
const res = await fetch("/" + rel, { cache:"no-cache" });
|
const src = await fetch(rel).then(r=>r.text());
|
||||||
if (!res.ok) throw new Error("not found");
|
const html = marked.parse(src);
|
||||||
const text = await res.text();
|
els.viewer.innerHTML = `<article>${html}</article>`;
|
||||||
|
|
||||||
const html = window.marked.parse(text, { mangle:false, headerIds:true });
|
|
||||||
const safe = window.DOMPurify.sanitize(html);
|
|
||||||
els.viewer.innerHTML = safe;
|
|
||||||
|
|
||||||
els.viewer.scrollIntoView({ block:"start", behavior:"instant" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderHTML(rel){
|
function renderHTML(rel){
|
||||||
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.border = "0";
|
|
||||||
iframe.style.margin = "0";
|
|
||||||
iframe.loading = "eager";
|
iframe.loading = "eager";
|
||||||
|
iframe.src = "/" + rel;
|
||||||
els.viewer.innerHTML = "";
|
els.viewer.innerHTML = "";
|
||||||
els.viewer.appendChild(iframe);
|
els.viewer.appendChild(iframe);
|
||||||
iframe.src = "/" + rel;
|
|
||||||
|
|
||||||
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{}
|
|
||||||
};
|
|
||||||
|
|
||||||
iframe.addEventListener("load", ()=>{
|
iframe.addEventListener("load", ()=>{
|
||||||
try{
|
try{
|
||||||
const d = iframe.contentDocument || iframe.contentWindow.document;
|
const d = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
|
||||||
// 🔒 Reset default UA margins so content sits flush like Markdown
|
|
||||||
const s = d.createElement("style");
|
const s = d.createElement("style");
|
||||||
s.textContent = `
|
s.textContent = `
|
||||||
html,body{margin:0;padding:0;background:transparent}
|
html,body{margin:0;padding:0;background:transparent;color:#e6e3d7;font:16px/1.6 Inter,ui-sans-serif;}
|
||||||
body>*:first-child{margin-top:0}
|
main,article,section{max-width:720px;margin:auto;padding:2rem;}
|
||||||
`;
|
`;
|
||||||
d.head.appendChild(s);
|
d.head.appendChild(s);
|
||||||
|
|
||||||
// Track dynamic growth
|
|
||||||
try{
|
|
||||||
const ro = new ResizeObserver(size);
|
|
||||||
ro.observe(d.documentElement);
|
|
||||||
ro.observe(d.body);
|
|
||||||
}catch{}
|
}catch{}
|
||||||
}catch{}
|
|
||||||
|
|
||||||
size();
|
|
||||||
setTimeout(size, 250);
|
|
||||||
setTimeout(size, 800);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Utils ---------- */
|
function renderDefault(){
|
||||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
const latest = [...indexData.flat].sort((a,b)=>b.mtime-a.mtime)[0];
|
||||||
const esc = s => s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
if (latest) location.hash = "#/" + latest.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue