Update app.js
This commit is contained in:
parent
b842ef88d0
commit
b52784805c
1 changed files with 137 additions and 211 deletions
186
public/app.js
186
public/app.js
|
|
@ -1,9 +1,4 @@
|
||||||
/* ============================================================
|
|
||||||
Self-Organizing Static Site Framework v2.3.3
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
let INDEX, CURRENT_PATH = null, PATH_TO_EL = new Map();
|
let INDEX, CURRENT_PATH = null, PATH_TO_EL = new Map();
|
||||||
|
|
||||||
const treeEl = document.getElementById("tree");
|
const treeEl = document.getElementById("tree");
|
||||||
const mdView = document.getElementById("mdView");
|
const mdView = document.getElementById("mdView");
|
||||||
const htmlView = document.getElementById("htmlView");
|
const htmlView = document.getElementById("htmlView");
|
||||||
|
|
@ -17,59 +12,26 @@ const sidebar = document.querySelector(".sidebar");
|
||||||
const navToggle = document.getElementById("navToggle");
|
const navToggle = document.getElementById("navToggle");
|
||||||
const overlay = document.querySelector(".overlay");
|
const overlay = document.querySelector(".overlay");
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Utility: ensure external libs are ready before running
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
async function ensureLibsReady() {
|
|
||||||
let tries = 0;
|
|
||||||
while ((!window.marked || !window.DOMPurify) && tries < 40) {
|
|
||||||
await new Promise(r => setTimeout(r, 100));
|
|
||||||
tries++;
|
|
||||||
}
|
|
||||||
if (!window.marked) console.warn("⚠️ marked.js not detected — markdown will show as plain text.");
|
|
||||||
if (!window.DOMPurify) console.warn("⚠️ DOMPurify not detected — HTML not sanitized.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Sidebar toggle and overlay
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
navToggle.addEventListener("click", () => sidebar.classList.toggle("open"));
|
navToggle.addEventListener("click", () => sidebar.classList.toggle("open"));
|
||||||
overlay.addEventListener("click", () => sidebar.classList.remove("open"));
|
overlay.addEventListener("click", () => sidebar.classList.remove("open"));
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Load and render index.json
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
async function loadIndex() {
|
async function loadIndex() {
|
||||||
await ensureLibsReady();
|
|
||||||
const res = await fetch("/index.json", { cache: "no-store" });
|
const res = await fetch("/index.json", { cache: "no-store" });
|
||||||
INDEX = await res.json();
|
INDEX = await res.json();
|
||||||
populateFilters();
|
populateFilters();
|
||||||
rebuildTree();
|
rebuildTree();
|
||||||
|
window.addEventListener("popstate", () => { const hp = location.hash.startsWith("#=") ? location.hash.slice(2) : null; if (hp) openPath(hp); });
|
||||||
window.addEventListener("popstate", () => {
|
const init = location.hash.startsWith("#=") ? location.hash.slice(2) : INDEX.flat.sort((a, b) => b.mtime - a.mtime)[0]?.path;
|
||||||
const hp = location.hash.startsWith("#=") ? location.hash.slice(2) : null;
|
|
||||||
if (hp) openPath(hp);
|
|
||||||
});
|
|
||||||
|
|
||||||
const init = location.hash.startsWith("#=")
|
|
||||||
? location.hash.slice(2)
|
|
||||||
: INDEX.flat.sort((a,b)=>b.mtime-a.mtime)[0]?.path;
|
|
||||||
|
|
||||||
openPath(init);
|
openPath(init);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateFilters() {
|
function populateFilters() {
|
||||||
filterSel.innerHTML = '<option value="all">All</option>';
|
filterSel.innerHTML = '<option value="all">All</option>';
|
||||||
for (const cat of INDEX.sections) {
|
for (const cat of INDEX.sections) {
|
||||||
const o = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
o.value = o.textContent = cat;
|
opt.value = opt.textContent = cat;
|
||||||
filterSel.appendChild(o);
|
filterSel.appendChild(opt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Build directory tree
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
function rebuildTree() {
|
function rebuildTree() {
|
||||||
treeEl.innerHTML = "";
|
treeEl.innerHTML = "";
|
||||||
PATH_TO_EL.clear();
|
PATH_TO_EL.clear();
|
||||||
|
|
@ -77,77 +39,66 @@ function rebuildTree() {
|
||||||
const sort = sortSel.value;
|
const sort = sortSel.value;
|
||||||
const query = searchBox.value.trim().toLowerCase();
|
const query = searchBox.value.trim().toLowerCase();
|
||||||
const root = { type: "dir", children: INDEX.tree };
|
const root = { type: "dir", children: INDEX.tree };
|
||||||
const pruned = filterTree(root, f =>
|
const pruned = filterTree(root, f => (filter === "all" || f.path.split("/")[0] === filter) && (!query || (f.title || f.name).toLowerCase().includes(query)));
|
||||||
(filter==="all" || f.path.split("/")[0]===filter) &&
|
|
||||||
(!query || (f.title||f.name).toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
sortDir(pruned, sort);
|
sortDir(pruned, sort);
|
||||||
for (const c of pruned.children) treeEl.appendChild(renderNode(c));
|
for (const c of pruned.children) treeEl.appendChild(renderNode(c));
|
||||||
treeEl.querySelectorAll(".dir").forEach(d => d.classList.add("open"));
|
treeEl.querySelectorAll(".dir").forEach(d => d.classList.add("open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTree(node, keep) {
|
function filterTree(node, keep) {
|
||||||
if (node.type === "file") return keep(node) ? node : null;
|
if (node.type === "file") return keep(node) ? node : null;
|
||||||
const kids = node.children.map(c => filterTree(c, keep)).filter(Boolean);
|
const kids = node.children.map(c => filterTree(c, keep)).filter(Boolean);
|
||||||
return kids.length ? { ...node, children: kids } : null;
|
return kids.length ? { ...node, children: kids } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortDir(node, sort) {
|
function sortDir(node, sort) {
|
||||||
const cmp = sort==="name" ? (a,b)=>a.name.localeCompare(b.name)
|
const cmp = sort === "name" ? (a, b) => a.name.localeCompare(b.name) :
|
||||||
: sort==="old" ? (a,b)=>a.mtime-b.mtime
|
sort === "old" ? (a, b) => a.mtime - b.mtime : (a, b) => b.mtime - a.mtime;
|
||||||
: (a,b)=>b.mtime-a.mtime;
|
node.children.sort((a, b) => (a.type === "dir" && b.type !== "dir") ? -1 : (a.type !== "dir" && b.type === "dir") ? 1 : cmp(a, b));
|
||||||
node.children.sort((a,b)=>
|
|
||||||
(a.type==="dir"&&b.type!=="dir")?-1:
|
|
||||||
(a.type!=="dir"&&b.type==="dir")?1:cmp(a,b));
|
|
||||||
node.children.forEach(c => c.type === "dir" && sortDir(c, sort));
|
node.children.forEach(c => c.type === "dir" && sortDir(c, sort));
|
||||||
}
|
}
|
||||||
|
function renderNode(node) {
|
||||||
function renderNode(n) {
|
if (node.type === "dir") {
|
||||||
if (n.type==="dir") {
|
const div = document.createElement("div");
|
||||||
const d = document.createElement("div");
|
div.className = "dir";
|
||||||
d.className="dir"; d.setAttribute("aria-expanded","false");
|
div.setAttribute("aria-expanded", "false");
|
||||||
const lbl = document.createElement("span");
|
const lbl = document.createElement("span");
|
||||||
lbl.className="label"; lbl.textContent=n.name||"/";
|
lbl.className = "label";
|
||||||
|
lbl.textContent = node.name || "/";
|
||||||
lbl.addEventListener("click", () => {
|
lbl.addEventListener("click", () => {
|
||||||
const idx=n.children.find(c=>c.type==="file"&&/^index\.(md|html)$/i.test(c.name));
|
const idx = node.children.find(c => c.type === "file" && /^index\.(md|html)$/i.test(c.name));
|
||||||
if(idx) openPath(idx.path); else d.classList.toggle("open");
|
if (idx) openPath(idx.path);
|
||||||
|
else div.classList.toggle("open");
|
||||||
});
|
});
|
||||||
d.appendChild(lbl);
|
div.appendChild(lbl);
|
||||||
const kids = document.createElement("div");
|
const kids = document.createElement("div");
|
||||||
kids.className = "children";
|
kids.className = "children";
|
||||||
n.children.forEach(c=>kids.appendChild(renderNode(c)));
|
node.children.forEach(c => kids.appendChild(renderNode(c)));
|
||||||
d.appendChild(kids);
|
div.appendChild(kids);
|
||||||
return d;
|
return div;
|
||||||
}
|
}
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = "file";
|
a.className = "file";
|
||||||
a.innerHTML=`${n.pinned?'📌 ':''}${iconForExt(n.ext)} ${n.title}
|
a.innerHTML = `${node.pinned ? '<span class="pin">📌</span>' : ''}${iconForExt(node.ext)} ${node.title} <span class="meta">(${fmtDate(node.mtime)} · ${node.name})</span>`;
|
||||||
<span class="meta">(${fmtDate(n.mtime)} · ${n.name})</span>`;
|
a.addEventListener("click", e => { e.preventDefault(); openPath(node.path); });
|
||||||
a.addEventListener("click",e=>{e.preventDefault();openPath(n.path);});
|
PATH_TO_EL.set(node.path, a);
|
||||||
PATH_TO_EL.set(n.path,a);
|
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconForExt(ext) { return ext === ".md" ? "📝" : "🧩"; }
|
function iconForExt(ext) { return ext === ".md" ? "📝" : "🧩"; }
|
||||||
function fmtDate(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
function fmtDate(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
||||||
|
function findDir(path) {
|
||||||
/* ------------------------------------------------------------
|
path = path.replace(/\/$/, '');
|
||||||
Path navigation
|
function search(node) {
|
||||||
------------------------------------------------------------- */
|
if (node.type === "dir" && node.path === path) return node;
|
||||||
function findDir(p){
|
for (const c of node.children || []) {
|
||||||
p=p.replace(/\/$/,'');
|
const found = search(c);
|
||||||
function search(n){
|
if (found) return found;
|
||||||
if(n.type==="dir"&&n.path===p) return n;
|
}
|
||||||
for(const c of n.children||[]){const f=search(c);if(f)return f;}
|
|
||||||
}
|
}
|
||||||
return search({ children: INDEX.tree });
|
return search({ children: INDEX.tree });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPath(path) {
|
async function openPath(path) {
|
||||||
if (path === CURRENT_PATH) return;
|
if (path === CURRENT_PATH) return;
|
||||||
CURRENT_PATH = path;
|
CURRENT_PATH = path;
|
||||||
if (location.hash !== `#=${path}`) history.pushState(null, "", `#=${path}`);
|
if (location.hash !== `#=${path}`) history.pushState(null, "", `#=${path}`);
|
||||||
|
|
||||||
let f = INDEX.flat.find(x => x.path === path);
|
let f = INDEX.flat.find(x => x.path === path);
|
||||||
if (!f) {
|
if (!f) {
|
||||||
const dir = findDir(path);
|
const dir = findDir(path);
|
||||||
|
|
@ -158,7 +109,6 @@ async function openPath(path){
|
||||||
metaLine.textContent = "Path not found: " + path;
|
metaLine.textContent = "Path not found: " + path;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
metaLine.textContent = `${f.pinned ? "📌 " : ""}${fmtDate(f.mtime)} • ${f.name}`;
|
metaLine.textContent = `${f.pinned ? "📌 " : ""}${fmtDate(f.mtime)} • ${f.name}`;
|
||||||
if (f.ext === ".md") await renderMarkdown(f.path);
|
if (f.ext === ".md") await renderMarkdown(f.path);
|
||||||
else renderHTML(f.path);
|
else renderHTML(f.path);
|
||||||
|
|
@ -166,51 +116,44 @@ async function openPath(path){
|
||||||
updatePager();
|
updatePager();
|
||||||
if (window.innerWidth < 900) sidebar.classList.remove("open");
|
if (window.innerWidth < 900) sidebar.classList.remove("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Markdown rendering (stable version)
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
async function renderMarkdown(path) {
|
async function renderMarkdown(path) {
|
||||||
mdView.innerHTML = "<p style='color:var(--muted);font-style:italic;'>Loading…</p>";
|
mdView.innerHTML = "<p style='color:var(--muted);font-style:italic;'>Loading…</p>";
|
||||||
htmlView.style.display="none";
|
|
||||||
mdView.style.display = "block";
|
mdView.style.display = "block";
|
||||||
|
htmlView.style.display = "none";
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/" + path);
|
const res = await fetch("/" + path);
|
||||||
if (!res.ok) throw new Error("File not found: " + path);
|
if (!res.ok) throw new Error("File not found: " + path);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
let html = text.replace(/&/g, '&').replace(/</g, '<');
|
||||||
let html=text.replace(/&/g,"&").replace(/</g,"<");
|
|
||||||
let usedFallback = true;
|
let usedFallback = true;
|
||||||
if(window.marked){ html=window.marked.parse(text); usedFallback=false; }
|
if (window.marked) {
|
||||||
|
html = window.marked.parse(text);
|
||||||
|
usedFallback = false;
|
||||||
|
}
|
||||||
let safe = html;
|
let safe = html;
|
||||||
if (window.DOMPurify) safe = window.DOMPurify.sanitize(html);
|
if (window.DOMPurify) safe = window.DOMPurify.sanitize(html);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
mdView.innerHTML = safe;
|
mdView.innerHTML = safe;
|
||||||
mdView.scrollTop=0; // reset scroll to top
|
mdView.scrollTop = 0;
|
||||||
mdView.classList.add("fade-in");
|
mdView.classList.add("fade-in");
|
||||||
mdView.style.display="block";
|
|
||||||
});
|
});
|
||||||
|
if (usedFallback) console.warn("Markdown rendered as plain text: marked.js not loaded. Check CDN or vendor locally.");
|
||||||
if(usedFallback) console.warn("Markdown rendered as plain text (marked.js missing).");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mdView.innerHTML = `<p style='color:red;'>${e.message}</p>`;
|
mdView.innerHTML = `<p style='color:red;'>${e.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
HTML rendering
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
function renderHTML(path) {
|
function renderHTML(path) {
|
||||||
htmlView.src="/"+path;
|
htmlView.style.display = "none";
|
||||||
htmlView.style.display="block";
|
|
||||||
mdView.style.display = "none";
|
mdView.style.display = "none";
|
||||||
|
metaLine.innerHTML += " <span style='color:var(--muted);'>Loading…</span>";
|
||||||
|
htmlView.src = "/" + path;
|
||||||
|
htmlView.onload = () => {
|
||||||
|
htmlView.style.display = "block";
|
||||||
|
htmlView.classList.add("fade-in");
|
||||||
|
metaLine.innerHTML = metaLine.innerHTML.replace(" Loading…", "");
|
||||||
|
};
|
||||||
|
htmlView.onerror = () => metaLine.innerHTML += " <span style='color:red;'>Load failed.</span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Active state + pager
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
function setActive(path) {
|
function setActive(path) {
|
||||||
document.querySelectorAll(".file.active").forEach(el => el.classList.remove("active"));
|
document.querySelectorAll(".file.active").forEach(el => el.classList.remove("active"));
|
||||||
const el = PATH_TO_EL.get(path);
|
const el = PATH_TO_EL.get(path);
|
||||||
|
|
@ -223,16 +166,11 @@ function setActive(path){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePager() {
|
function updatePager() {
|
||||||
const q=searchBox.value.trim().toLowerCase();
|
const query = searchBox.value.trim().toLowerCase();
|
||||||
const list=INDEX.flat.filter(f=>
|
const list = INDEX.flat.filter(f => (filterSel.value === "all" || f.path.split("/")[0] === filterSel.value) && (!query || f.title.toLowerCase().includes(query)));
|
||||||
(filterSel.value==="all"||f.path.split("/")[0]===filterSel.value)&&
|
const cmp = sortSel.value === "name" ? (a, b) => a.name.localeCompare(b.name) :
|
||||||
(!q||f.title.toLowerCase().includes(q))
|
sortSel.value === "old" ? (a, b) => a.mtime - b.mtime : (a, b) => b.mtime - a.mtime;
|
||||||
);
|
|
||||||
const cmp=sortSel.value==="name"?(a,b)=>a.name.localeCompare(b.name)
|
|
||||||
:sortSel.value==="old"?(a,b)=>a.mtime-b.mtime
|
|
||||||
:(a,b)=>b.mtime-a.mtime;
|
|
||||||
list.sort(cmp);
|
list.sort(cmp);
|
||||||
const i = list.findIndex(x => x.path === CURRENT_PATH);
|
const i = list.findIndex(x => x.path === CURRENT_PATH);
|
||||||
prevBtn.disabled = i <= 0;
|
prevBtn.disabled = i <= 0;
|
||||||
|
|
@ -240,21 +178,10 @@ function updatePager(){
|
||||||
prevBtn.onclick = () => i > 0 && openPath(list[i - 1].path);
|
prevBtn.onclick = () => i > 0 && openPath(list[i - 1].path);
|
||||||
nextBtn.onclick = () => i < list.length - 1 && openPath(list[i + 1].path);
|
nextBtn.onclick = () => i < list.length - 1 && openPath(list[i + 1].path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Search / filter / sort
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
searchBox.addEventListener("input",()=>{
|
searchBox.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(rebuildTree, 300); });
|
||||||
clearTimeout(searchTimer);
|
|
||||||
searchTimer=setTimeout(rebuildTree,300);
|
|
||||||
});
|
|
||||||
sortSel.addEventListener("change", rebuildTree);
|
sortSel.addEventListener("change", rebuildTree);
|
||||||
filterSel.addEventListener("change", rebuildTree);
|
filterSel.addEventListener("change", rebuildTree);
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
Internal link interception
|
|
||||||
------------------------------------------------------------- */
|
|
||||||
document.body.addEventListener("click", e => {
|
document.body.addEventListener("click", e => {
|
||||||
const a = e.target.closest("a[href]");
|
const a = e.target.closest("a[href]");
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
|
|
@ -264,6 +191,5 @@ document.body.addEventListener("click",e=>{
|
||||||
openPath(href.replace(/^\//, ""));
|
openPath(href.replace(/^\//, ""));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => { if (window.innerWidth < 900) sidebar.classList.remove("open"); });
|
window.addEventListener("resize", () => { if (window.innerWidth < 900) sidebar.classList.remove("open"); });
|
||||||
window.addEventListener("DOMContentLoaded", loadIndex);
|
window.addEventListener("DOMContentLoaded", loadIndex);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue