Update app.js
This commit is contained in:
parent
8753a11823
commit
4f762442ce
1 changed files with 240 additions and 142 deletions
382
public/app.js
382
public/app.js
|
|
@ -1,154 +1,252 @@
|
||||||
/* ============================================================
|
/* global marked, DOMPurify */
|
||||||
The Fold Within — app.js v2.6.2
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
============================================================ */
|
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
const state = {
|
||||||
const sidebar = document.querySelector(".sidebar");
|
index: null,
|
||||||
const overlay = document.querySelector(".overlay");
|
sidebarOpen: false, // mobile overlay (<=1024)
|
||||||
const navToggle = document.getElementById("navToggle");
|
desktopCollapsed: false, // desktop collapse
|
||||||
const content = document.querySelector(".content");
|
sort: "newest",
|
||||||
const mdView = document.getElementById("mdView");
|
section: "all",
|
||||||
const htmlView = document.getElementById("htmlView");
|
q: ""
|
||||||
|
};
|
||||||
|
|
||||||
let currentPath = "";
|
const els = {
|
||||||
let usedFallback = false;
|
body: document.body,
|
||||||
|
sidebar: $("#sidebar"),
|
||||||
|
content: $("#content"),
|
||||||
|
viewer: $("#viewer"),
|
||||||
|
tree: $("#tree"),
|
||||||
|
routeHint: $("#routeHint"),
|
||||||
|
navToggle: $("#navToggle"),
|
||||||
|
filterSection: $("#filterSection"),
|
||||||
|
sortOrder: $("#sortOrder"),
|
||||||
|
searchBox: $("#searchBox"),
|
||||||
|
};
|
||||||
|
|
||||||
/* ============================================================
|
init();
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
overlay.addEventListener("click", () => {
|
async function init(){
|
||||||
sidebar.classList.remove("open");
|
// restore preferences
|
||||||
overlay.classList.remove("active");
|
try {
|
||||||
});
|
state.desktopCollapsed = localStorage.getItem("desktopCollapsed") === "1";
|
||||||
|
} catch {}
|
||||||
|
|
||||||
/* ============================================================
|
// set collapse classes for initial paint
|
||||||
Load External Libraries (Marked + DOMPurify)
|
if (window.matchMedia("(min-width:1025px)").matches) {
|
||||||
============================================================ */
|
els.body.classList.toggle("sidebar-collapsed", state.desktopCollapsed);
|
||||||
async function ensureLibsReady(timeoutMs = 4000) {
|
els.body.classList.add("sidebar-open"); // open on desktop unless collapsed
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
els.navToggle.addEventListener("click", toggleSidebar);
|
||||||
File Loading + Rendering
|
|
||||||
============================================================ */
|
|
||||||
async function loadFile(path) {
|
|
||||||
if (!path) return;
|
|
||||||
currentPath = path;
|
|
||||||
|
|
||||||
|
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 = `<p style="color:#f66">Could not load index.json</p>`;
|
||||||
|
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 =>
|
||||||
|
`<option value="${s}">${capitalize(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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<a href="#=${encodeURIComponent(f.path)}" data-path="${f.path}">
|
||||||
|
<div class="title">${escapeHtml(f.title)}</div>
|
||||||
|
<div class="meta">${meta}</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).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 = `
|
||||||
|
<div class="empty">
|
||||||
|
<h1>The Fold Within</h1>
|
||||||
|
<p>Select a note on the left.</p>
|
||||||
|
</div>`;
|
||||||
|
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 {
|
try {
|
||||||
const res = await fetch(path);
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
if (!res.ok) throw new Error(`Path not found: ${path}`);
|
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();
|
iframe.addEventListener("load", () => {
|
||||||
const text = await res.text();
|
sizeIframe();
|
||||||
|
// resize observer for dynamic html (images etc.)
|
||||||
if (ext === "md" || ext === "markdown") {
|
try {
|
||||||
await ensureLibsReady();
|
const ro = new ResizeObserver(sizeIframe);
|
||||||
renderMarkdown(text);
|
ro.observe(iframe.contentDocument.documentElement);
|
||||||
} else if (ext === "html" || ext === "htm") {
|
} catch { /* not critical */ }
|
||||||
renderHTML(text);
|
// also a delayed pass for images/fonts
|
||||||
} else {
|
setTimeout(sizeIframe, 250);
|
||||||
mdView.innerHTML = `<div class="md-warn">Unsupported file type: ${ext}</div>`;
|
setTimeout(sizeIframe, 800);
|
||||||
htmlView.innerHTML = "";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
mdView.innerHTML = `<div class="md-warn">${err.message}</div>`;
|
|
||||||
htmlView.innerHTML = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Renderers
|
|
||||||
============================================================ */
|
|
||||||
function renderMarkdown(text) {
|
|
||||||
const safe = window.DOMPurify
|
|
||||||
? DOMPurify.sanitize(window.marked.parse(text))
|
|
||||||
: text
|
|
||||||
.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
||||||
mdView.innerHTML = usedFallback
|
|
||||||
? `<div class='md-warn'>Markdown fallback to plain text (libs failed). Check console.</div><pre>${safe}</pre>`
|
|
||||||
: 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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ---------- Helpers ---------- */
|
||||||
Fade Animation Helper
|
function capitalize(s){ return s.charAt(0).toUpperCase() + s.slice(1) }
|
||||||
============================================================ */
|
function escapeHtml(s){ return s.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])) }
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue