Update app.js
This commit is contained in:
parent
7d4d9471b5
commit
9f55ba2a7c
1 changed files with 78 additions and 180 deletions
258
public/app.js
258
public/app.js
|
|
@ -1,192 +1,90 @@
|
||||||
/* 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 INDEX = null;
|
||||||
let CURRENT_PATH = null;
|
let CURRENT_PATH = null;
|
||||||
let PATH_TO_EL = new Map();
|
|
||||||
|
|
||||||
const treeEl = document.getElementById("tree");
|
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 metaEl = document.getElementById("meta");
|
||||||
|
const mdView = document.getElementById("mdView");
|
||||||
|
const htmlView = document.getElementById("htmlView");
|
||||||
|
const sortSel = document.getElementById("sortSel");
|
||||||
|
const filterSel = document.getElementById("filterSel");
|
||||||
|
const navToggle = document.getElementById("navToggle");
|
||||||
|
|
||||||
const ICONS = {
|
navToggle.addEventListener("click", () =>
|
||||||
".md": "📝",
|
document.querySelector(".sidebar").classList.toggle("open")
|
||||||
".html": "🧩",
|
);
|
||||||
"dir": "🗂️"
|
|
||||||
};
|
|
||||||
|
|
||||||
function icon(ext){ return ICONS[ext] || "📄"; }
|
async function loadIndex() {
|
||||||
|
try {
|
||||||
function humanDate(ms){
|
const res = await fetch("index.json");
|
||||||
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 = `<span>${icon("dir")}</span><strong>${node.name}</strong>`;
|
|
||||||
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 ? '<span class="pin">PIN</span>' : ''}${icon(node.ext)} ${node.title} <span class="meta">${humanDate(node.mtime)} · ${node.name}</span>`;
|
|
||||||
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) : `<pre>${txt.replace(/[&<>]/g, s=>({ "&":"&","<":"<",">":">" }[s]))}</pre>`);
|
|
||||||
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();
|
INDEX = await res.json();
|
||||||
} catch (e){
|
} catch {
|
||||||
treeEl.innerHTML = "<p style='color:#f66'>index.json missing. run the build.</p>";
|
treeEl.innerHTML = "<p style='color:red'>index.json missing. run the build.</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sortSel.addEventListener("change", rebuildTree);
|
|
||||||
filterSel.addEventListener("change", rebuildTree);
|
|
||||||
|
|
||||||
rebuildTree();
|
rebuildTree();
|
||||||
|
autoOpenLatest();
|
||||||
// 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();
|
function rebuildTree() {
|
||||||
|
treeEl.innerHTML = "";
|
||||||
|
const roots = ["pinned", "posts"];
|
||||||
|
for (const root of roots) {
|
||||||
|
const dir = INDEX.tree.find(d => d.name === root);
|
||||||
|
if (dir) {
|
||||||
|
const h = document.createElement("div");
|
||||||
|
h.className = "dir";
|
||||||
|
h.textContent = root;
|
||||||
|
treeEl.appendChild(h);
|
||||||
|
dir.children.forEach(f => treeEl.appendChild(renderFile(f)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFile(f) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.className = "file";
|
||||||
|
a.textContent = (f.pinned ? "📌 " : "") + f.name;
|
||||||
|
a.addEventListener("click", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
openPath(f.path);
|
||||||
|
});
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoOpenLatest() {
|
||||||
|
if (!INDEX.flat?.length) return;
|
||||||
|
const sorted = [...INDEX.flat].sort((a,b)=>b.mtime - a.mtime);
|
||||||
|
openPath(sorted[0].path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPath(path) {
|
||||||
|
if (path === CURRENT_PATH) return;
|
||||||
|
CURRENT_PATH = path;
|
||||||
|
|
||||||
|
const f = INDEX.flat.find(x => x.path === path);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
metaEl.textContent = `${f.pinned ? "Pinned • " : ""}${new Date(f.mtime).toISOString().slice(0,10)} • ${f.name}`;
|
||||||
|
|
||||||
|
if (f.ext === ".md") await renderMarkdown(path);
|
||||||
|
else await renderHTML(path);
|
||||||
|
|
||||||
|
if (window.innerWidth < 900)
|
||||||
|
document.querySelector(".sidebar").classList.remove("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMarkdown(path) {
|
||||||
|
htmlView.style.display = "none";
|
||||||
|
mdView.style.display = "block";
|
||||||
|
const res = await fetch(path);
|
||||||
|
const text = await res.text();
|
||||||
|
const html = DOMPurify.sanitize(marked.parse(text));
|
||||||
|
mdView.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderHTML(path) {
|
||||||
|
mdView.style.display = "none";
|
||||||
|
htmlView.style.display = "block";
|
||||||
|
htmlView.src = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", loadIndex);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue