ops design and app
add more comments feature, more tags feature, and other frontend updates
This commit is contained in:
@@ -3,15 +3,69 @@
|
||||
|
||||
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
|
||||
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
|
||||
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
|
||||
const article = document.querySelector('[data-document-path]');
|
||||
const workspaceShell = article?.closest('.workspace-shell--document');
|
||||
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
|
||||
const stripTrack = document.querySelector('[data-comments-strip-track]');
|
||||
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
|
||||
const commentToggle = document.querySelector('[data-comment-toggle]');
|
||||
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
|
||||
|
||||
function readCommentVisibility() {
|
||||
if (!commentVisibilityKey) return false;
|
||||
try {
|
||||
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!documentId) return;
|
||||
|
||||
// Hash a paragraph's text content for anchoring
|
||||
function syncVisibility() {
|
||||
const hidden = readCommentVisibility();
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
}
|
||||
|
||||
syncVisibility();
|
||||
|
||||
function commentsHidden() {
|
||||
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
|
||||
}
|
||||
|
||||
function setCommentsHidden(hidden) {
|
||||
if (article) article.classList.toggle('comments-hidden', hidden);
|
||||
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
|
||||
if (commentVisibilityKey) {
|
||||
try {
|
||||
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
|
||||
} catch (err) {
|
||||
// Ignore persistence failures
|
||||
}
|
||||
}
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
|
||||
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
|
||||
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
|
||||
}
|
||||
if (!hidden) {
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
}
|
||||
}
|
||||
|
||||
if (commentToggle) {
|
||||
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
|
||||
commentToggle.addEventListener('click', () => {
|
||||
setCommentsHidden(!commentsHidden());
|
||||
});
|
||||
}
|
||||
|
||||
function hashParagraph(el) {
|
||||
const text = (el.textContent || '').trim();
|
||||
if (!text) return null;
|
||||
// Simple hash: first 16 chars of base64 of char codes (deterministic)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
@@ -24,29 +78,46 @@
|
||||
function addCommentButton(el, hash) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'comment-anchor-btn';
|
||||
btn.type = 'button';
|
||||
btn.title = 'Add comment';
|
||||
btn.innerHTML = '+';
|
||||
btn.setAttribute('aria-label', 'Add comment');
|
||||
btn.innerHTML = `
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
<path d="M12 8v4"></path>
|
||||
<path d="M10 10h4"></path>
|
||||
</svg>
|
||||
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
||||
<span class="sr-only">Add comment</span>
|
||||
`;
|
||||
btn.addEventListener('click', () => promptComment(el, hash));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
|
||||
function updateCommentButtonState(el, count) {
|
||||
const btn = el.querySelector('.comment-anchor-btn');
|
||||
if (!btn) return;
|
||||
const hasComments = count > 0;
|
||||
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
|
||||
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
|
||||
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
|
||||
const countNode = btn.querySelector('.comment-anchor-count');
|
||||
if (countNode) {
|
||||
countNode.hidden = !hasComments;
|
||||
countNode.textContent = count > 99 ? '99+' : String(count);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptComment(el, anchorHash) {
|
||||
const text = window.prompt('Comment on this paragraph:');
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
versionHash: documentHash,
|
||||
content: text.trim(),
|
||||
anchorHash,
|
||||
}),
|
||||
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
// Refresh comments display
|
||||
await loadCommentsForAnchor(el, anchorHash);
|
||||
} catch (err) {
|
||||
window.alert('Failed to post comment: ' + err.message);
|
||||
@@ -65,11 +136,17 @@
|
||||
}
|
||||
|
||||
function renderComments(el, comments) {
|
||||
// Remove existing comment list
|
||||
const existing = el.querySelector('.comment-list');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!comments.length) return;
|
||||
updateCommentButtonState(el, comments.length);
|
||||
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
|
||||
|
||||
if (!comments.length) {
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'comment-list';
|
||||
@@ -77,15 +154,165 @@
|
||||
const item = document.createElement('div');
|
||||
item.className = 'comment-item';
|
||||
item.innerHTML = `
|
||||
<div class="comment-meta">
|
||||
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
|
||||
<time>${formatDate(c.createdAt)}</time>
|
||||
</div>
|
||||
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
||||
<div class="comment-body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
el.appendChild(list);
|
||||
|
||||
renderCommentsAside();
|
||||
updateStripMarkers();
|
||||
}
|
||||
|
||||
function renderCommentsAside() {
|
||||
if (!commentsAsideList) return;
|
||||
commentsAsideList.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
if (!commentables.length) {
|
||||
commentsAsideList.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
commentables.forEach(el => {
|
||||
const comments = [];
|
||||
el.querySelectorAll('.comment-item').forEach(item => {
|
||||
const meta = item.querySelector('.comment-meta');
|
||||
const bodyEl = item.querySelector('.comment-body');
|
||||
comments.push({
|
||||
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
|
||||
createdAt: meta?.querySelector('time')?.textContent || '',
|
||||
content: bodyEl?.textContent || '',
|
||||
});
|
||||
});
|
||||
if (!comments.length) return;
|
||||
|
||||
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
|
||||
|
||||
const asideItem = document.createElement('div');
|
||||
asideItem.className = 'comments-aside-item';
|
||||
asideItem.dataset.anchorHash = el.dataset.anchorHash;
|
||||
|
||||
const targetEl = document.createElement('div');
|
||||
targetEl.className = 'comments-aside-item__target';
|
||||
targetEl.textContent = truncated || 'Commented section';
|
||||
targetEl.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
asideItem.appendChild(targetEl);
|
||||
|
||||
comments.forEach(c => {
|
||||
const commentEl = document.createElement('div');
|
||||
commentEl.className = 'comments-aside-item__comment';
|
||||
commentEl.innerHTML = `
|
||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
asideItem.appendChild(commentEl);
|
||||
});
|
||||
|
||||
commentsAsideList.appendChild(asideItem);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function updateStripMarkers() {
|
||||
if (!stripTrack) return;
|
||||
stripTrack.innerHTML = '';
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (!body || !shell) return;
|
||||
|
||||
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
|
||||
const shellRect = shell.getBoundingClientRect();
|
||||
const trackHeight = stripTrack.clientHeight;
|
||||
|
||||
commentables.forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
|
||||
const percent = (relativeTop / shell.scrollHeight) * 100;
|
||||
const clamped = Math.max(0, Math.min(95, percent));
|
||||
|
||||
const marker = document.createElement('button');
|
||||
marker.className = 'document-comments-strip__marker';
|
||||
marker.type = 'button';
|
||||
marker.style.top = clamped + '%';
|
||||
marker.dataset.anchorHash = el.dataset.anchorHash;
|
||||
marker.title = 'Jump to comments';
|
||||
marker.addEventListener('click', () => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (commentsHidden()) setCommentsHidden(false);
|
||||
// Highlight the corresponding sidebar item
|
||||
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
|
||||
if (asideItem) {
|
||||
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
asideItem.classList.add('is-highlighted');
|
||||
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
|
||||
}
|
||||
});
|
||||
stripTrack.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
let bidirectionalHoverInitialized = false;
|
||||
function initBidirectionalHover() {
|
||||
if (bidirectionalHoverInitialized) return;
|
||||
bidirectionalHoverInitialized = true;
|
||||
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body || !commentsAsideList) return;
|
||||
|
||||
// Body → sidebar + marker
|
||||
body.addEventListener('mouseenter', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
el.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
body.addEventListener('mouseleave', (e) => {
|
||||
const el = e.target.closest('.commentable[data-has-comments="true"]');
|
||||
if (!el) return;
|
||||
const hash = el.dataset.anchorHash;
|
||||
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (asideItem) asideItem.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
el.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
|
||||
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
|
||||
commentsAsideList.addEventListener('mouseenter', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.add('is-highlighted');
|
||||
if (marker) marker.classList.add('is-active');
|
||||
item.classList.add('is-highlighted');
|
||||
}, true);
|
||||
|
||||
commentsAsideList.addEventListener('mouseleave', (e) => {
|
||||
const item = e.target.closest('.comments-aside-item');
|
||||
if (!item) return;
|
||||
const hash = item.dataset.anchorHash;
|
||||
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
|
||||
if (para) para.classList.remove('is-highlighted');
|
||||
if (marker) marker.classList.remove('is-active');
|
||||
item.classList.remove('is-highlighted');
|
||||
}, true);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -99,7 +326,6 @@
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
// Initialize: hash all paragraphs in markdown body
|
||||
function init() {
|
||||
const body = document.querySelector('.markdown-body');
|
||||
if (!body) return;
|
||||
@@ -110,9 +336,21 @@
|
||||
if (!hash) return;
|
||||
el.dataset.anchorHash = hash;
|
||||
el.classList.add('commentable');
|
||||
addCommentButton(el, hash);
|
||||
if (canComment) addCommentButton(el, hash);
|
||||
loadCommentsForAnchor(el, hash);
|
||||
});
|
||||
|
||||
renderCommentsAside();
|
||||
initBidirectionalHover();
|
||||
|
||||
// Update markers on scroll/resize
|
||||
const shell = document.querySelector('.document-shell');
|
||||
if (shell) {
|
||||
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
|
||||
}
|
||||
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
|
||||
// Initial marker update after layout settles
|
||||
setTimeout(updateStripMarkers, 100);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -120,4 +358,4 @@
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
})();
|
||||
36
apps/server/internal/httpserver/static/permissions.js
Normal file
36
apps/server/internal/httpserver/static/permissions.js
Normal file
@@ -0,0 +1,36 @@
|
||||
(() => {
|
||||
const root = document.querySelector("[data-permission-bulk]");
|
||||
if (!root) return;
|
||||
|
||||
const selectAll = root.querySelector("[data-permission-select-all]");
|
||||
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
|
||||
const apply = root.querySelector("[data-permission-apply]");
|
||||
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
|
||||
|
||||
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
|
||||
|
||||
const updateSelectAll = () => {
|
||||
const selected = checkedRows().length;
|
||||
selectAll.checked = selected > 0 && selected === rows.length;
|
||||
selectAll.indeterminate = selected > 0 && selected < rows.length;
|
||||
};
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
rows.forEach((row) => {
|
||||
const checkbox = row.querySelector("[data-permission-user]");
|
||||
if (checkbox) checkbox.checked = selectAll.checked;
|
||||
});
|
||||
updateSelectAll();
|
||||
});
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
|
||||
});
|
||||
|
||||
apply?.addEventListener("click", () => {
|
||||
checkedRows().forEach((row) => {
|
||||
const select = row.querySelector("[data-permission-select]");
|
||||
if (select) select.value = bulkLevel.value;
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
const html = document.documentElement.outerHTML;
|
||||
const title = document.title;
|
||||
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
|
||||
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
|
||||
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
|
||||
}
|
||||
|
||||
@@ -373,4 +373,72 @@
|
||||
}).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ const STATIC_ASSETS = [
|
||||
"/static/realtime.js",
|
||||
"/static/editor.js",
|
||||
"/static/render.js",
|
||||
"/static/tags.js",
|
||||
"/static/favicon.png",
|
||||
"/static/cairnquire%20logo%402x.webp",
|
||||
"/",
|
||||
@@ -64,12 +65,13 @@ self.addEventListener("fetch", function (event) {
|
||||
});
|
||||
|
||||
async function handleNavigation(request, url) {
|
||||
const cacheKey = url.pathname + url.search;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
cachePageResponse(url.pathname, response.clone());
|
||||
cachePageResponse(cacheKey, response.clone());
|
||||
return response;
|
||||
} catch (_error) {
|
||||
const cachedPage = await getCachedPage(url.pathname);
|
||||
const cachedPage = await getCachedPage(cacheKey);
|
||||
if (cachedPage && cachedPage.html) {
|
||||
return new Response(markOfflineHTML(cachedPage.html), {
|
||||
headers: {
|
||||
@@ -80,6 +82,19 @@ async function handleNavigation(request, url) {
|
||||
});
|
||||
}
|
||||
|
||||
if (cacheKey !== "/") {
|
||||
const fallback = await getCachedPage("/");
|
||||
if (fallback && fallback.html) {
|
||||
return new Response(markOfflineHTML(fallback.html), {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-MDHub-Cache": "offline",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await caches.match("/");
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
|
||||
81
apps/server/internal/httpserver/static/tags.js
Normal file
81
apps/server/internal/httpserver/static/tags.js
Normal file
@@ -0,0 +1,81 @@
|
||||
(function () {
|
||||
const url = new URL(window.location.href);
|
||||
const tag = url.searchParams.get("tag");
|
||||
if (!tag) return;
|
||||
|
||||
// Check if we're on a proper tag page or a fallback
|
||||
const hasTagPreview = document.querySelector(".tag-preview");
|
||||
if (hasTagPreview) return; // Server rendered tag page correctly
|
||||
|
||||
// We're on a fallback page (likely offline) — build tag view from cache
|
||||
if (!window.MDHubCache) return;
|
||||
|
||||
window.MDHubCache.getCachedDocuments().then(function (docs) {
|
||||
if (!docs || !docs.length) return;
|
||||
|
||||
const tagged = docs.filter(function (doc) {
|
||||
return doc.tags && doc.tags.indexOf(tag) !== -1;
|
||||
});
|
||||
|
||||
// Build a minimal tag browser
|
||||
const shell = document.querySelector(".workspace-shell");
|
||||
if (!shell) return;
|
||||
|
||||
const browser = shell.querySelector(".miller-browser");
|
||||
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
|
||||
|
||||
if (browser) {
|
||||
// Replace browser with a single column of tagged docs
|
||||
const itemsHtml = tagged
|
||||
.sort(function (a, b) {
|
||||
const aName = (a.title || a.path).toLowerCase();
|
||||
const bName = (b.title || b.path).toLowerCase();
|
||||
return aName < bName ? -1 : aName > bName ? 1 : 0;
|
||||
})
|
||||
.map(function (doc) {
|
||||
const name = doc.title || doc.path;
|
||||
const path = doc.path.replace(/\.md$/, "");
|
||||
return (
|
||||
'<li><a href="/docs/' +
|
||||
escapeHtml(path) +
|
||||
'" title="' +
|
||||
escapeHtml(name) +
|
||||
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
|
||||
escapeHtml(name) +
|
||||
"</span></span></a></li>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
browser.innerHTML =
|
||||
'<section class="miller-column miller-column--root" aria-label="#' +
|
||||
escapeHtml(tag) +
|
||||
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
|
||||
escapeHtml(tag) +
|
||||
"</span></span></h2><ul>" +
|
||||
itemsHtml +
|
||||
"</ul></section>";
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
preview.outerHTML =
|
||||
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
|
||||
escapeHtml(tag) +
|
||||
'</h1><p class="lede">' +
|
||||
tagged.length +
|
||||
" document" +
|
||||
(tagged.length === 1 ? "" : "s") +
|
||||
" found</p></section>";
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user