archiving
Some checks failed
Build Roam Site / build (push) Has been cancelled

This commit is contained in:
2026-06-03 14:12:58 +01:00
parent a758ae1793
commit ac1d3839ba
22 changed files with 1180 additions and 285 deletions

View File

@@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
initMediaFixes(document);
initReadingProgress();
hookSearchResults();
initHomeDashboard();
});
/* =========================================================
@@ -93,14 +94,14 @@ function rearrangeDOM() {
function buildNoteToolbar(toolbar) {
toolbar.innerHTML = `
<button class="toolbar-btn" id="tb-back" title="Go back" aria-label="Go back">\u2190 back</button>
<button class="toolbar-btn" id="tb-forward" title="Go forward" aria-label="Go forward">forward \u2192</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-back" title="Go back" aria-label="Go back">\u2190</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-forward" title="Go forward" aria-label="Go forward">\u2192</button>
<div class="toolbar-sep"></div>
<button class="toolbar-btn" id="tb-toc" title="Toggle table of contents">contents</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-toc" title="Toggle table of contents" aria-label="Toggle table of contents">\u2261</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-backlinks" title="Toggle backlinks panel" aria-label="Toggle backlinks panel">\u29C9</button>
<div class="toolbar-spacer"></div>
<span class="note-graph-pos" id="note-graph-pos"></span>
<div class="toolbar-sep"></div>
<button class="toolbar-btn" id="tb-backlinks" title="Toggle backlinks panel">backlinks</button>
<button class="toolbar-btn toolbar-btn-icon" id="tb-close-all" title="Close all notes" aria-label="Close all notes">\u2715</button>
`;
}
@@ -108,19 +109,49 @@ function buildNoteToolbar(toolbar) {
SIDEBAR RAIL
========================================================= */
const RECENT_KEY = 'zg-recent-notes';
const RECENT_MAX = 12;
const FOLDER_STATE_KEY = 'zg-folder-state';
let sidebarDataCache = null;
async function fetchSidebarData() {
if (sidebarDataCache) return sidebarDataCache;
try {
const res = await fetch(`/assets/sidebar-tree.json?v=${Date.now()}`, { cache: 'no-store' });
if (res.ok) sidebarDataCache = await res.json();
} catch (_) {}
return sidebarDataCache || { mocs: [], tree: [] };
}
function closeSidebar() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
sidebar.classList.remove('expanded', 'mobile-open');
sidebar.dataset.activePanel = '';
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
document.getElementById('sidebar-backdrop')?.classList.remove('visible');
}
function initSidebarRail() {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
const rail = el('div', { id: 'sidebar-rail', class: 'sidebar-rail' });
const body = el('div', { id: 'sidebar-body', class: 'sidebar-body' });
sidebar.append(rail, body);
const railBtns = [
{ label: '\u2630', title: 'All notes', id: 'rail-tree', panel: 'tree' },
{ label: '\u2315', title: 'Search', id: 'rail-search', action: 'search' },
{ label: '\u2B21', title: 'Atlas', id: 'rail-atlas', panel: 'atlas' },
{ label: '\u25F7', title: 'Recent', id: 'rail-recent', panel: 'recent' },
{ label: '\u2630', title: 'Browse', id: 'rail-browse', panel: 'browse' },
{ label: '\u2315', title: 'Search', id: 'rail-search', action: 'search' },
];
railBtns.forEach(({ label, title, id, panel, action }) => {
const btn = el('button', { class: 'rail-btn', id, title, 'aria-label': title });
btn.textContent = label;
sidebar.appendChild(btn);
rail.appendChild(btn);
btn.addEventListener('click', () => {
if (action === 'search') {
@@ -131,13 +162,12 @@ function initSidebarRail() {
}
return;
}
const isExpanded = sidebar.classList.contains('expanded');
const samePanel = sidebar.dataset.activePanel === panel;
if (isExpanded && samePanel) {
sidebar.classList.remove('expanded');
sidebar.dataset.activePanel = '';
btn.classList.remove('active');
closeSidebar();
} else {
sidebar.classList.add('expanded');
sidebar.dataset.activePanel = panel;
@@ -148,57 +178,142 @@ function initSidebarRail() {
});
});
// Mobile backdrop
let backdrop = document.getElementById('sidebar-backdrop');
if (!backdrop) {
backdrop = el('div', { id: 'sidebar-backdrop' });
document.body.appendChild(backdrop);
}
backdrop.addEventListener('click', () => {
sidebar.classList.remove('expanded', 'mobile-open');
backdrop.classList.remove('visible');
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
});
backdrop.addEventListener('click', closeSidebar);
initSidebarToggle();
}
function showSidebarPanel(panel) {
const sidebar = document.getElementById('sidebar');
sidebar.querySelectorAll('.sidebar-panel').forEach(p => p.remove());
const body = document.getElementById('sidebar-body');
if (!body) return;
body.innerHTML = '';
if (panel === 'tree') {
const panelEl = el('div', { class: 'sidebar-panel' });
panelEl.innerHTML = `
<div class="sidebar-header">
<span class="sidebar-header-label">all notes</span>
<button class="sidebar-close-btn" aria-label="Close sidebar">\u00D7</button>
</div>
<div id="file-tree"></div>
`;
sidebar.appendChild(panelEl);
const panelEl = el('div', { class: 'sidebar-panel', 'data-panel': panel });
panelEl.innerHTML = `
<div class="sidebar-header">
<span class="sidebar-header-label">${panel}</span>
<button class="sidebar-close-btn" aria-label="Close sidebar">\u00D7</button>
</div>
<div class="sidebar-panel-body" id="sidebar-panel-body"></div>
`;
body.appendChild(panelEl);
panelEl.querySelector('.sidebar-close-btn').addEventListener('click', () => {
sidebar.classList.remove('expanded');
sidebar.dataset.activePanel = '';
sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active'));
});
panelEl.querySelector('.sidebar-close-btn').addEventListener('click', closeSidebar);
buildFileTree(panelEl.querySelector('#file-tree'));
const container = panelEl.querySelector('#sidebar-panel-body');
if (panel === 'atlas') buildAtlasPanel(container);
else if (panel === 'recent') buildRecentPanel(container);
else if (panel === 'browse') buildBrowsePanel(container);
}
async function buildAtlasPanel(container) {
const data = await fetchSidebarData();
const mocs = data.mocs || [];
if (!mocs.length) {
container.innerHTML = '<p class="sidebar-empty">No MOC hubs found.</p>';
return;
}
const grid = el('div', { class: 'moc-grid' });
mocs.forEach(moc => {
const card = el('button', {
class: 'moc-card' + (moc.pinned ? ' moc-card-pinned' : ''),
type: 'button',
});
card.innerHTML = `
<span class="moc-card-title">${escHtml(moc.title)}</span>
${moc.pinned ? '<span class="moc-card-badge">start</span>' : ''}
`;
card.addEventListener('click', () => {
closeSidebar();
openNote(normalisePathname(moc.url), moc.title);
});
grid.appendChild(card);
});
container.appendChild(grid);
}
function getRecentNotes() {
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch (_) {
return [];
}
}
function trackRecentNote(pathname, title) {
if (!pathname) return;
const entry = { pathname, title, visitedAt: Date.now() };
let recent = getRecentNotes().filter(r => r.pathname !== pathname);
recent.unshift(entry);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, RECENT_MAX)));
const panel = document.querySelector('.sidebar-panel[data-panel="recent"] #sidebar-panel-body');
if (panel) buildRecentPanel(panel);
}
function formatRelativeTime(ts) {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}d ago`;
return new Date(ts).toLocaleDateString();
}
function buildRecentPanel(container) {
const recent = getRecentNotes();
container.innerHTML = '';
if (!recent.length) {
container.innerHTML = '<p class="sidebar-empty">No notes visited yet.</p>';
return;
}
const list = el('div', { class: 'recent-list' });
recent.forEach(item => {
const row = el('button', { class: 'recent-item', type: 'button' });
row.innerHTML = `
<span class="recent-item-title">${escHtml(item.title)}</span>
<span class="recent-item-time">${formatRelativeTime(item.visitedAt)}</span>
`;
row.addEventListener('click', () => {
closeSidebar();
openNote(item.pathname, item.title);
});
list.appendChild(row);
});
container.appendChild(list);
}
function buildBrowsePanel(container) {
container.innerHTML = `
<div class="tree-filter-wrap">
<input type="search" class="tree-filter" placeholder="Filter notes\u2026" aria-label="Filter notes" />
</div>
<div id="file-tree"></div>
`;
const filterInput = container.querySelector('.tree-filter');
const treeRoot = container.querySelector('#file-tree');
filterInput.addEventListener('input', () => filterTree(treeRoot, filterInput.value.trim()));
buildFileTree(treeRoot);
}
async function buildFileTree(container) {
const currentPath = location.pathname;
let treeData = null;
try {
const res = await fetch(`/assets/sidebar-tree.json?v=${Date.now()}`, { cache: 'no-store' });
if (res.ok) {
const json = await res.json();
if (Array.isArray(json.tree)) treeData = json.tree;
}
} catch (_) {}
const data = await fetchSidebarData();
let treeData = Array.isArray(data.tree) ? data.tree : null;
if (treeData) {
renderTree(treeData, container, currentPath);
@@ -207,45 +322,120 @@ async function buildFileTree(container) {
const groups = await buildGroupsFromSearchIndex();
if (!groups?.length) {
const empty = el('div', { class: 'tree-file' });
empty.style.cssText = 'opacity:.4;font-size:.7rem;padding:8px 14px;';
empty.textContent = 'no notes found';
container.appendChild(empty);
container.innerHTML = '<p class="sidebar-empty">No notes found.</p>';
return;
}
const fallbackTree = groups.map(g => ({ label: g.label, children: [], files: g.files }));
renderTree(fallbackTree, container, currentPath);
}
function renderTree(nodes, container, currentPath) {
function getFolderState() {
try {
return JSON.parse(localStorage.getItem(FOLDER_STATE_KEY) || '{}');
} catch (_) {
return {};
}
}
function setFolderState(key, isOpen) {
const state = getFolderState();
state[key] = isOpen;
localStorage.setItem(FOLDER_STATE_KEY, JSON.stringify(state));
}
function countFilesInNode(node) {
let count = (node.files || []).length;
for (const child of node.children || []) {
count += countFilesInNode(child);
}
return count;
}
function nodeContainsPath(node, pathname, prefix = '') {
const folderKey = prefix ? `${prefix}/${node.label}` : node.label;
for (const f of node.files || []) {
if (normalisePathname(f.url) === pathname) return true;
}
for (const child of node.children || []) {
if (nodeContainsPath(child, pathname, folderKey)) return true;
}
return false;
}
function renderTree(nodes, container, currentPath, prefix = '') {
nodes.forEach(node => {
const folderEl = el('div', { class: 'tree-folder', 'data-folder': node.label });
const icon = el('span', { class: 'tree-icon' });
icon.textContent = '\u25BE ';
const folderKey = prefix ? `${prefix}/${node.label}` : node.label;
const fileCount = countFilesInNode(node);
const containsActive = nodeContainsPath(node, currentPath, prefix);
const folderState = getFolderState();
const defaultOpen = containsActive || folderState[folderKey] === true;
const isOpen = defaultOpen;
const folderEl = el('div', {
class: 'tree-folder',
'data-folder': node.label,
'data-folder-key': folderKey,
});
const icon = el('span', { class: 'tree-icon' });
icon.textContent = isOpen ? '\u25BE ' : '\u25B8 ';
const labelSpan = el('span', { class: 'tree-folder-label' });
labelSpan.textContent = node.label;
folderEl.append(icon, labelSpan);
const badge = el('span', { class: 'tree-folder-count' });
badge.textContent = String(fileCount);
folderEl.append(icon, labelSpan, badge);
const childrenWrap = el('div', { class: 'tree-children' });
if (!isOpen) childrenWrap.style.display = 'none';
const searchTerms = [node.label.toLowerCase()];
(node.files || []).forEach(f => {
const pathname = normalisePathname(f.url);
searchTerms.push(f.title.toLowerCase());
childrenWrap.appendChild(makeFileItem({ title: f.title, pathname }, currentPath));
});
if (node.children?.length) renderTree(node.children, childrenWrap, currentPath);
if (node.children?.length) {
renderTree(node.children, childrenWrap, currentPath, folderKey);
}
folderEl.addEventListener('click', () => {
const isOpen = childrenWrap.style.display !== 'none';
childrenWrap.style.display = isOpen ? 'none' : '';
icon.textContent = isOpen ? '\u25B8 ' : '\u25BE ';
const nowOpen = childrenWrap.style.display === 'none';
childrenWrap.style.display = nowOpen ? '' : 'none';
icon.textContent = nowOpen ? '\u25BE ' : '\u25B8 ';
setFolderState(folderKey, nowOpen);
});
folderEl.dataset.searchText = searchTerms.join(' ');
childrenWrap.dataset.searchText = searchTerms.join(' ');
container.appendChild(folderEl);
container.appendChild(childrenWrap);
});
}
function filterTree(container, query) {
const q = query.toLowerCase();
container.querySelectorAll('.tree-folder, .tree-children, .tree-file').forEach(el => {
if (!q) {
el.classList.remove('tree-filtered-out');
return;
}
const text = (el.dataset.searchText || el.textContent || '').toLowerCase();
el.classList.toggle('tree-filtered-out', !text.includes(q));
});
if (q) {
container.querySelectorAll('.tree-children').forEach(wrap => {
wrap.style.display = '';
const folder = wrap.previousElementSibling;
if (folder?.classList.contains('tree-folder')) {
const icon = folder.querySelector('.tree-icon');
if (icon) icon.textContent = '\u25BE ';
}
});
}
}
async function buildGroupsFromSearchIndex() {
try {
const res = await fetch('/search-index.json');
@@ -267,14 +457,17 @@ function makeFileItem({ title, pathname }, currentPath) {
const fileEl = el('div', {
class: 'tree-file' + (pathname === currentPath ? ' active' : ''),
'data-pathname': pathname,
'data-search-text': title.toLowerCase(),
});
const iconSpan = el('span', { class: 'tree-icon' });
iconSpan.textContent = '\u00B7 ';
const titleSpan = el('span', { class: 'tree-file-title' });
titleSpan.textContent = title;
fileEl.appendChild(iconSpan);
fileEl.appendChild(document.createTextNode(title));
fileEl.appendChild(titleSpan);
fileEl.title = title;
fileEl.addEventListener('click', () => {
document.getElementById('sidebar')?.classList.remove('expanded');
closeSidebar();
openNote(pathname, title);
});
return fileEl;
@@ -378,6 +571,7 @@ history.replaceState(
document.getElementById('tb-forward')?.addEventListener('click', goForward);
document.getElementById('tb-backlinks')?.addEventListener('click', toggleBacklinkPanel);
document.getElementById('tb-toc')?.addEventListener('click', toggleTOC);
document.getElementById('tb-close-all')?.addEventListener('click', closeAllNotes);
// Backlink panel close
document.getElementById('bp-close')?.addEventListener('click', () => {
@@ -548,6 +742,11 @@ function activateNote(id, skipBrowserWrite = true) {
const area = document.getElementById('content-area');
if (area) area.scrollTop = 0;
const current = noteHistory[historyIndex];
if (current) {
trackRecentNote(current.pathname, current.label);
}
updateBreadcrumb();
updateToolbar();
@@ -567,6 +766,18 @@ function goForward() {
window.history.forward();
}
function closeAllNotes() {
if (noteHistory.length <= 1) return;
const root = noteHistory[0];
noteHistory.slice(1).forEach(n => {
if (!n.isRoot) n.paneEl.remove();
});
noteHistory.splice(1);
historyIndex = 0;
activateNote(root.id, true);
history.replaceState(makeHistoryState(root), '', historyUrlFor(root));
}
function updateBreadcrumb() {
const trail = document.getElementById('breadcrumb-trail');
if (!trail) return;
@@ -934,9 +1145,9 @@ function initSidebarToggle() {
e.preventDefault();
const nowExpanded = sidebar.classList.toggle('expanded');
if (nowExpanded && !sidebar.dataset.activePanel) {
sidebar.dataset.activePanel = 'tree';
showSidebarPanel('tree');
sidebar.querySelector('#rail-tree')?.classList.add('active');
sidebar.dataset.activePanel = 'atlas';
showSidebarPanel('atlas');
sidebar.querySelector('#rail-atlas')?.classList.add('active');
}
if (!nowExpanded) {
sidebar.dataset.activePanel = '';
@@ -1285,6 +1496,51 @@ function normalisePathname(p) {
// Lazy sidebar builder stub (built on demand)
async function buildSidebar() {}
async function initHomeDashboard() {
const pathname = normalisePathname(location.pathname);
if (pathname !== '/' && !pathname.endsWith('/index.html')) return;
const content = document.querySelector('#content');
if (!content || content.querySelector('.home-dashboard')) return;
const data = await fetchSidebarData();
const mocs = data.mocs || [];
if (!mocs.length) return;
const dash = el('div', { class: 'home-dashboard' });
dash.innerHTML = `
<header class="home-hero">
<h2 class="home-hero-title">Zettelgarten</h2>
<p class="home-hero-sub">A private library of notes, maps, and wandering thoughts.</p>
</header>
<div class="home-quick-links">
<button type="button" class="home-quick-link" data-action="search">Search <kbd>/</kbd></button>
<a class="home-quick-link" href="/all-files.html">All files</a>
</div>
<h3 class="home-section-label">Start here</h3>
<div class="home-moc-grid"></div>
`;
const grid = dash.querySelector('.home-moc-grid');
mocs.forEach(moc => {
const card = el('a', {
class: 'moc-card home-moc-card' + (moc.pinned ? ' moc-card-pinned' : ''),
href: moc.url,
});
card.innerHTML = `
<span class="moc-card-title">${escHtml(moc.title)}</span>
${moc.pinned ? '<span class="moc-card-badge">start</span>' : ''}
`;
grid.appendChild(card);
});
dash.querySelector('[data-action="search"]')?.addEventListener('click', () => {
window.openSearchModal?.();
});
content.insertBefore(dash, content.firstChild);
}
// Pane-out animation keyframe
const _s = document.createElement('style');
_s.textContent = `