feat: miller column browser, offline cache, search, breadcrumbs

- Dynamic miller column sizing with flexbox layout
- Root column (160px), middle slices (48px), active column (flex)
- Auto-scroll browser to rightmost column on navigation
- Offline indicator UI and IndexedDB cache integration
- /api/documents endpoint for client-side document caching
- Breadcrumb navigation in document content area
- FTS5 search with SQLite virtual table
- Client-side Mermaid and KaTeX rendering via CDN
- Deep sample content (advanced-topics/raft-deep-dive)
- Border removal (only search button keeps radius)
- Full viewport layout with proper borders between sidebar/content
This commit is contained in:
2026-04-30 11:55:03 -04:00
parent e45eeeb600
commit 780ff3a02c
40 changed files with 2986 additions and 190 deletions

View File

@@ -1,35 +1,225 @@
(function () {
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
const documentShell = document.querySelector("[data-document-path][data-document-hash]");
const browser = document.querySelector(".miller-browser");
if (!notice || !reload || !documentShell || !window.WebSocket) {
// Auto-scroll miller browser to show the rightmost (active) column
function scrollBrowserToRight() {
if (!browser) return;
requestAnimationFrame(function () {
// Only scroll if the rightmost column is not already visible
var tolerance = 50;
var maxScroll = browser.scrollWidth - browser.clientWidth;
if (maxScroll <= 0) return; // everything fits, no scroll needed
if (browser.scrollLeft >= maxScroll - tolerance) return; // already at right edge
browser.scrollTo({
left: browser.scrollWidth,
behavior: "smooth",
});
});
}
scrollBrowserToRight();
function updateOfflineUI() {
if (!offlineNotice) return;
if (navigator.onLine) {
offlineNotice.hidden = true;
} else {
offlineNotice.hidden = false;
}
}
window.addEventListener("online", function () {
updateOfflineUI();
fetchAndCacheDocuments();
});
window.addEventListener("offline", function () {
updateOfflineUI();
restoreBrowserFromCache();
});
updateOfflineUI();
// Cache current page content
if (documentShell && window.MDHubCache) {
const path = documentShell.getAttribute("data-document-path");
const title = document.title;
const html = documentShell.querySelector(".markdown-body")?.innerHTML;
if (path && html) {
window.MDHubCache.cacheContent(path, html, title).catch(function () {});
}
}
// Fetch and cache documents
function fetchAndCacheDocuments() {
if (!window.MDHubCache) return;
fetch("/api/documents")
.then(function (res) {
if (!res.ok) throw new Error("fetch failed");
return res.json();
})
.then(function (data) {
if (data.documents) {
window.MDHubCache.cacheDocuments(data.documents).catch(function () {});
}
})
.catch(function () {});
}
fetchAndCacheDocuments();
// Restore browser from cache when offline
function restoreBrowserFromCache() {
if (!browser || !window.MDHubCache) return;
window.MDHubCache.getCachedDocuments().then(function (docs) {
if (!docs || docs.length === 0) return;
// The browser is already rendered server-side; we just keep it.
// If we wanted to rebuild it from cache, we'd need the full logic.
// For now, the existing browser HTML is likely cached by the browser itself.
}).catch(function () {});
}
// Intercept browser navigation when offline
if (browser) {
browser.addEventListener("click", function (event) {
const link = event.target.closest("a");
if (!link) return;
if (navigator.onLine) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("//")) return;
// Try to serve from cache for document pages
if (href.startsWith("/docs/")) {
event.preventDefault();
const path = href.replace("/docs/", "");
window.MDHubCache.getCachedContent(path).then(function (cached) {
if (cached && cached.html) {
loadCachedDocument(path, cached.title, cached.html);
} else {
window.location.href = href;
}
}).catch(function () {
window.location.href = href;
});
}
});
}
function loadCachedDocument(path, title, html) {
if (!documentShell) {
window.location.href = "/docs/" + path;
return;
}
// Update URL without reloading
history.pushState({ cachedPath: path }, title, "/docs/" + path);
document.title = title;
// Update document shell
documentShell.setAttribute("data-document-path", path);
documentShell.setAttribute("data-document-hash", "");
const titleEl = documentShell.querySelector(".document-meta h1");
if (titleEl) titleEl.textContent = title;
const pathEl = documentShell.querySelector(".meta-grid code");
if (pathEl) pathEl.textContent = path;
const bodyEl = documentShell.querySelector(".markdown-body");
if (bodyEl) bodyEl.innerHTML = html;
// Re-render math and mermaid
if (typeof renderMath === "function") renderMath();
if (typeof renderMermaid === "function") renderMermaid();
// Update active state in browser
updateBrowserActiveState(path);
}
function updateBrowserActiveState(activePath) {
if (!browser) return;
browser.querySelectorAll("a.is-active").forEach(function (el) {
el.classList.remove("is-active");
});
browser.querySelectorAll("a").forEach(function (link) {
const href = link.getAttribute("href");
if (href === "/docs/" + activePath || href === "/?folder=" + activePath) {
link.classList.add("is-active");
}
});
}
// WebSocket realtime updates
if (!window.WebSocket) {
return;
}
const currentPath = documentShell.getAttribute("data-document-path");
const currentHash = documentShell.getAttribute("data-document-hash");
const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null;
const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(protocol + "//" + window.location.host + "/ws");
let reconnectTimer = null;
socket.addEventListener("message", function (event) {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
function connect() {
const socket = new WebSocket(protocol + "//" + window.location.host + "/ws");
socket.addEventListener("open", function () {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
});
socket.addEventListener("message", function (event) {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (payload.type !== "document_version" || !payload.data) {
return;
}
const change = payload.data;
if (currentPath && change.path === currentPath && change.hash !== currentHash) {
if (notice) notice.hidden = false;
return;
}
if (browser && !documentShell) {
if (notice) notice.hidden = false;
}
});
socket.addEventListener("close", function () {
reconnectTimer = setTimeout(connect, 3000);
});
socket.addEventListener("error", function () {
socket.close();
});
}
connect();
if (reload) {
reload.addEventListener("click", function () {
window.location.reload();
});
}
// Handle back/forward buttons for cached documents
window.addEventListener("popstate", function (event) {
if (event.state && event.state.cachedPath && window.MDHubCache) {
window.MDHubCache.getCachedContent(event.state.cachedPath).then(function (cached) {
if (cached && cached.html) {
loadCachedDocument(event.state.cachedPath, cached.title, cached.html);
}
}).catch(function () {});
}
if (payload.type !== "document_version" || !payload.data) {
return;
}
if (payload.data.path === currentPath && payload.data.hash !== currentHash) {
notice.hidden = false;
}
});
reload.addEventListener("click", function () {
window.location.reload();
});
})();