361 lines
15 KiB
JavaScript
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();
|
|
})();
|