SharedDocsLib/docslib/templates/client.js
2026-08-09 17:59:49 +00:00

361 lines
15 KiB
JavaScript

(() => {
"use strict";
const embedded = JSON.parse(document.getElementById("docslib-manifest").textContent);
const config = JSON.parse(document.getElementById("docslib-config").textContent);
const elements = {
title: document.getElementById("site-title"),
tree: document.getElementById("page-tree"),
content: document.getElementById("content"),
breadcrumbs: document.getElementById("breadcrumbs"),
previous: document.getElementById("previous-page"),
next: document.getElementById("next-page"),
status: document.getElementById("update-status"),
searchDialog: document.getElementById("search-dialog"),
searchInput: document.getElementById("search-input"),
searchResults: document.getElementById("search-results"),
themeIcon: document.getElementById("theme-icon"),
headerButtons: document.getElementById("header-buttons"),
headerActions: document.getElementById("header-actions"),
headerActionsAnchor: document.getElementById("header-actions-anchor"),
openActions: document.getElementById("open-actions"),
workspace: document.querySelector(".workspace"),
};
let manifest = embedded;
let currentNumber = null;
const mobileLayout = matchMedia("(max-width: 800px)");
const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB unavailable"));
const request = indexedDB.open("docslib-cache", 1);
request.onupgradeneeded = () => request.result.createObjectStore("manifests");
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
async function cacheGet() {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const request = db.transaction("manifests", "readonly").objectStore("manifests").get(config.cacheKey);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function cachePut(value) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const transaction = db.transaction("manifests", "readwrite");
transaction.objectStore("manifests").put(value, config.cacheKey);
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
});
}
function manifestIdentity(value = manifest) {
const updated = String(value.updated_at || "")
.replace("T", " ")
.replace(/\.\d+(?=Z|[+-])/, "")
.replace("+00:00", "Z");
const revision = String(value.revision || "").slice(0, 8);
return [updated, revision].filter(Boolean).join(" · ") || "—";
}
function setStatus(state = "", detail = "") {
elements.status.className = `update-status ${state}`.trim();
elements.status.querySelector("span").textContent = manifestIdentity();
elements.status.title = detail || String(manifest.revision || "");
}
function routeNumber() {
const match = location.hash.match(/^#\/page\/(.+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
function navigate(number, replace = false) {
const hash = `#/page/${encodeURIComponent(number)}`;
if (replace) history.replaceState(null, "", hash);
else if (location.hash !== hash) location.hash = hash;
renderPage(number);
document.body.classList.remove("sidebar-open");
document.body.classList.remove("actions-open");
}
function placeHeaderActions() {
if (mobileLayout.matches) {
if (elements.headerActions.parentElement !== document.body) document.body.append(elements.headerActions);
} else {
if (elements.headerActions.previousElementSibling !== elements.headerActionsAnchor) {
elements.headerActionsAnchor.after(elements.headerActions);
}
document.body.classList.remove("actions-open");
}
}
function makeTree() {
elements.tree.replaceChildren();
const root = { children: new Map(), page: null };
manifest.pages.forEach((page) => {
let node = root;
page.number.split(".").forEach((part) => {
if (!node.children.has(part)) node.children.set(part, { children: new Map(), page: null });
node = node.children.get(part);
});
node.page = page;
});
const appendNodes = (parent, node) => {
node.children.forEach((child) => {
const group = document.createElement("div");
group.className = "tree-group";
if (child.page) {
const button = document.createElement("button");
button.className = "tree-link";
button.dataset.page = child.page.number;
const number = document.createElement("span");
number.className = "tree-number";
number.textContent = child.page.number;
const title = document.createElement("span");
title.className = "tree-title";
title.textContent = child.page.title;
button.append(number, title);
button.addEventListener("click", () => navigate(child.page.number));
group.append(button);
}
if (child.children.size) {
const nested = document.createElement("div");
nested.className = "tree-children";
appendNodes(nested, child);
group.append(nested);
}
parent.append(group);
});
};
appendNodes(elements.tree, root);
}
function assetDataUri(name) {
const asset = manifest.assets && manifest.assets[name];
return asset ? `data:${asset.mime};base64,${asset.data}` : "";
}
function hydrateAssets() {
elements.content.querySelectorAll("[data-docslib-asset]").forEach((element) => {
const name = element.dataset.docslibAsset;
const uri = assetDataUri(name);
if (uri && element.tagName === "IMG") element.src = uri;
else if (!uri) {
element.setAttribute("title", name);
element.classList.add("missing-asset");
}
});
}
function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
currentNumber = page.number;
if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`);
document.title = `${page.title} · ${manifest.title}`;
elements.content.innerHTML = page.content || "";
hydrateAssets();
elements.content.querySelectorAll("a[href^='#/page/']").forEach((link) => {
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
navigate(decodeURIComponent(link.hash.slice(7)));
});
});
elements.tree.querySelectorAll(".tree-link").forEach((button) => button.classList.toggle("active", button.dataset.page === page.number));
elements.headerButtons.querySelectorAll("[data-page]").forEach((button) => {
const target = button.dataset.page;
button.classList.toggle("active", page.number === target || page.number.startsWith(`${target}.`));
});
elements.breadcrumbs.replaceChildren();
const numberParts = page.number.split(".");
numberParts.forEach((_, partIndex) => {
const candidate = manifest.pages.find((item) => item.number === numberParts.slice(0, partIndex + 1).join("."));
if (candidate) {
const crumb = document.createElement("span");
crumb.textContent = candidate.title;
elements.breadcrumbs.append(crumb);
}
});
setupNavButton(elements.previous, manifest.pages[index - 1], "←");
setupNavButton(elements.next, manifest.pages[index + 1], "→");
elements.content.focus({ preventScroll: true });
elements.workspace.scrollTo({ top: 0, behavior: "instant" });
}
function setupNavButton(button, page, direction) {
button.disabled = !page;
button.replaceChildren();
if (!page) return;
const small = document.createElement("small");
small.textContent = direction;
const title = document.createElement("span");
title.textContent = page.title;
button.append(small, title);
button.onclick = () => navigate(page.number);
}
function renderHeaderButtons() {
elements.headerButtons.replaceChildren();
const buttons = (manifest.ui && manifest.ui.header_buttons) || [];
buttons.forEach((button) => {
if (!button.label || (!button.href && !button.page)) return;
const link = document.createElement("a");
link.className = "header-link";
link.textContent = button.label;
if (button.page) {
link.href = `#/page/${encodeURIComponent(button.page)}`;
link.dataset.page = button.page;
link.addEventListener("click", (event) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
navigate(button.page);
});
} else {
link.href = button.href;
}
if (!button.page && button.target === "_blank") {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
link.addEventListener("click", () => document.body.classList.remove("actions-open"));
elements.headerButtons.append(link);
});
elements.openActions.hidden = buttons.length === 0;
}
function renderAll() {
document.documentElement.lang = "und";
elements.title.textContent = manifest.title;
makeTree();
renderHeaderButtons();
renderPage(routeNumber() || currentNumber || (manifest.pages[0] && manifest.pages[0].number));
setStatus();
}
function plainText(html) {
const node = document.createElement("div");
node.innerHTML = html;
return (node.textContent || "").replace(/\s+/g, " ").trim();
}
function search(query) {
const needle = query.trim().toLocaleLowerCase();
const matches = !needle ? manifest.pages : manifest.pages.filter((page) =>
`${page.number} ${page.title} ${plainText(page.content)}`.toLocaleLowerCase().includes(needle)
);
elements.searchResults.replaceChildren();
matches.slice(0, 30).forEach((page) => {
const button = document.createElement("button");
button.className = "search-result";
const title = document.createElement("strong");
title.textContent = `${page.number} ${page.title}`;
const excerpt = document.createElement("span");
excerpt.textContent = plainText(page.content).slice(0, 150) || "—";
button.append(title, excerpt);
button.addEventListener("click", () => {
elements.searchDialog.close();
navigate(page.number);
});
elements.searchResults.append(button);
});
if (!matches.length) elements.searchResults.innerHTML = '<div class="empty-state">∅</div>';
}
function openSearch() {
if (!elements.searchDialog.open) elements.searchDialog.showModal();
elements.searchInput.value = "";
search("");
setTimeout(() => elements.searchInput.focus(), 0);
}
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
elements.themeIcon.textContent = theme === "dark" ? "☀" : "◐";
localStorage.setItem("docslib-theme", theme);
}
async function syncManifest({ reload = true } = {}) {
setStatus("syncing");
try {
const response = await fetch(config.manifestUrl, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const remote = await response.json();
if (!remote.schema_version || !Array.isArray(remote.pages)) throw new Error("Invalid manifest");
if (remote.revision !== manifest.revision || remote.updated_at !== manifest.updated_at) {
await cachePut(remote).catch(() => {});
manifest = remote;
if (reload) {
sessionStorage.setItem("docslib-updated", remote.revision || remote.updated_at);
location.reload();
return;
}
renderAll();
setStatus();
} else {
setStatus();
}
} catch (error) {
setStatus("error", String(error));
console.warn("SharedDocsLib update check failed:", error);
}
}
async function pollLive() {
try {
const response = await fetch(config.liveUrl, { cache: "no-store" });
if (!response.ok) return;
const live = await response.json();
if (live.error) setStatus("error", live.error);
else if (live.revision && live.revision !== manifest.revision) await syncManifest({ reload: false });
} catch (_) { /* The normal manifest remains usable when the dev server stops. */ }
}
async function start() {
applyTheme(localStorage.getItem("docslib-theme") || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
try {
const cached = await cacheGet();
if (
cached &&
cached.schema_version === embedded.schema_version &&
String(cached.updated_at || "") >= String(embedded.updated_at || "")
) manifest = cached;
else await cachePut(embedded);
} catch (error) {
console.warn("SharedDocsLib cache unavailable:", error);
}
renderAll();
if (sessionStorage.getItem("docslib-updated")) sessionStorage.removeItem("docslib-updated");
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200);
}
document.getElementById("search-button").addEventListener("click", openSearch);
document.getElementById("close-search").addEventListener("click", () => elements.searchDialog.close());
elements.searchInput.addEventListener("input", () => search(elements.searchInput.value));
document.getElementById("theme-button").addEventListener("click", () => applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
document.getElementById("open-sidebar").addEventListener("click", () => document.body.classList.add("sidebar-open"));
document.getElementById("close-sidebar").addEventListener("click", () => document.body.classList.remove("sidebar-open"));
document.getElementById("sidebar-overlay").addEventListener("click", () => document.body.classList.remove("sidebar-open"));
document.getElementById("open-actions").addEventListener("click", () => document.body.classList.add("actions-open"));
document.getElementById("close-actions").addEventListener("click", () => document.body.classList.remove("actions-open"));
document.getElementById("actions-overlay").addEventListener("click", () => document.body.classList.remove("actions-open"));
if (mobileLayout.addEventListener) mobileLayout.addEventListener("change", placeHeaderActions);
else mobileLayout.addListener(placeHeaderActions);
window.addEventListener("hashchange", () => renderPage(routeNumber()));
window.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { event.preventDefault(); openSearch(); }
if (event.key === "Escape" && elements.searchDialog.open) elements.searchDialog.close();
});
placeHeaderActions();
start();
})();