Files
cairnquire/apps/server/internal/httpserver/static/realtime.js
Tim Bendt 04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00

445 lines
14 KiB
JavaScript

(function () {
const realtimeCallbacks = {
notification: [],
};
window.__cairnquireRealtime = {
on: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event].push(cb);
}
},
off: function (event, cb) {
if (realtimeCallbacks[event]) {
realtimeCallbacks[event] = realtimeCallbacks[event].filter(function (c) {
return c !== cb;
});
}
},
};
const notice = document.querySelector("[data-version-notice]");
const reload = document.querySelector("[data-version-reload]");
const offlineNotice = document.querySelector("[data-offline-notice]");
const cachedNotice = document.querySelector("[data-cached-notice]");
const documentShell = document.querySelector("[data-document-path][data-document-hash]");
const browser = document.querySelector(".miller-browser");
const isEditing = Boolean(document.querySelector("[data-editor-form]"));
// 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 initColumnPreviewExpansion() {
if (!browser) {
return;
}
let hoverTimer = null;
let expandedColumn = null;
function isCompressedColumn(column) {
return (
column &&
column.matches(".miller-column:not(:first-child):not(:last-child)")
);
}
function collapseColumn(column) {
if (!column) return;
column.classList.remove("is-preview-expanded");
column.style.removeProperty("--expanded-column-width");
if (expandedColumn === column) {
expandedColumn = null;
}
}
function expandColumn(column) {
if (!isCompressedColumn(column)) return;
if (expandedColumn && expandedColumn !== column) {
collapseColumn(expandedColumn);
}
const expandedWidth = Math.min(Math.max(column.scrollWidth + 12, 192), 384);
column.style.setProperty("--expanded-column-width", expandedWidth + "px");
column.classList.add("is-preview-expanded");
expandedColumn = column;
requestAnimationFrame(function () {
const leftEdge = column.offsetLeft;
const rightEdge = leftEdge + expandedWidth;
let targetLeft = browser.scrollLeft;
if (leftEdge < browser.scrollLeft) {
targetLeft = leftEdge - 8;
} else if (rightEdge > browser.scrollLeft + browser.clientWidth) {
targetLeft = rightEdge - browser.clientWidth + 8;
}
browser.scrollTo({
left: Math.max(0, targetLeft),
behavior: "smooth",
});
});
}
function scheduleExpansion(column) {
if (!isCompressedColumn(column)) return;
clearTimeout(hoverTimer);
hoverTimer = setTimeout(function () {
expandColumn(column);
}, 1000);
}
browser.querySelectorAll(".miller-column").forEach(function (column) {
column.addEventListener("pointerenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseenter", function () {
scheduleExpansion(column);
});
column.addEventListener("mouseover", function () {
if (!isCompressedColumn(column)) return;
if (expandedColumn === column) return;
scheduleExpansion(column);
});
column.addEventListener("pointerleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
column.addEventListener("mouseleave", function () {
clearTimeout(hoverTimer);
collapseColumn(column);
});
});
}
initColumnPreviewExpansion();
function updateCachedUI(isCached) {
if (!cachedNotice) return;
cachedNotice.hidden = !isCached;
}
function cacheCurrentPage() {
if (!window.MDHubCache) return;
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js").catch(function () {});
});
}
registerServiceWorker();
updateCachedUI(Boolean(window.__MDHUB_OFFLINE_CACHED__));
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 () {});
}
}
cacheCurrentPage();
// 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;
updateCachedUI(true);
cacheCurrentPage();
// 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 ? documentShell.getAttribute("data-document-path") : null;
const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let reconnectTimer = null;
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 === "notification") {
realtimeCallbacks.notification.forEach(function (cb) {
cb(payload.data);
});
return;
}
if (payload.type !== "document_version" || !payload.data) {
return;
}
const change = payload.data;
if (isEditing && currentPath && change.path === currentPath) {
return;
}
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 () {});
}
});
// Add folder button handlers
if (browser) {
browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var folderName = window.prompt("Folder name:");
if (!folderName || !folderName.trim()) return;
folderName = folderName.trim().replace(/[\\/]/g, "-");
var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md";
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create folder");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
browser.querySelectorAll(".miller-column-new-file").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var fileName = window.prompt("File name:");
if (!fileName || !fileName.trim()) return;
fileName = fileName.trim().replace(/[\\/]/g, "-");
if (!fileName.endsWith(".md")) fileName += ".md";
var docPath = parentPath ? parentPath + "/" + fileName : fileName;
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create file");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
}
})();