/* ========================================================= ZETTELKASTEN GARDEN script.js ========================================================= */ document.addEventListener('DOMContentLoaded', () => { rearrangeDOM(); buildSidebar(); initNoteSystem(); initSidebarRail(); initBacklinkPanel(); initLinkPreviews(); initCopyButtons(); initFootnoteSidenotes(); initThemeToggle(); initCountdowns(); initTOCHighlighting(); initMediaFixes(document); initReadingProgress(); hookSearchResults(); initHomeDashboard(); }); /* ========================================================= DOM REARRANGEMENT ========================================================= */ function rearrangeDOM() { const body = document.body; const preamble = document.getElementById('preamble'); const stackRoot = document.getElementById('stack-root'); const postamble = document.getElementById('postamble'); if (!stackRoot) return; const content = stackRoot.querySelector('#content'); // Inject breadcrumb trail into header const bannerHeader = preamble?.querySelector('.banner-header'); if (bannerHeader) { const bannerLeft = bannerHeader.querySelector('.banner-left'); const bannerSearch = bannerHeader.querySelector('.banner-search'); const crumbTrail = el('div', { id: 'breadcrumb-trail', 'aria-label': 'Navigation history' }); if (bannerLeft && bannerSearch) { bannerHeader.insertBefore(crumbTrail, bannerSearch); } else { bannerHeader.appendChild(crumbTrail); } } const layoutBody = el('div', { id: 'layout-body' }); const sidebar = el('nav', { id: 'sidebar', 'aria-label': 'Navigation' }); const main = el('div', { id: 'main' }); const noteToolbar = el('div', { id: 'note-toolbar' }); const mainInner = el('div', { id: 'main-inner' }); const contentArea = el('div', { id: 'content-area' }); const backlinkPnl = el('div', { id: 'backlink-panel', role: 'complementary', 'aria-label': 'Backlinks' }); buildNoteToolbar(noteToolbar); backlinkPnl.innerHTML = `
backlinks

No backlinks to this note.

`; if (content) { const rootPane = el('div', { class: 'tab-content active', 'data-tab-id': 'tab-root' }); rootPane.appendChild(content); contentArea.appendChild(rootPane); } mainInner.appendChild(contentArea); mainInner.appendChild(backlinkPnl); main.appendChild(noteToolbar); main.appendChild(mainInner); layoutBody.appendChild(sidebar); layoutBody.appendChild(main); stackRoot.remove(); const progress = el('div', { id: 'reading-progress' }); body.appendChild(progress); if (preamble?.nextSibling) { body.insertBefore(layoutBody, preamble.nextSibling); } else { body.appendChild(layoutBody); } if (postamble) body.appendChild(postamble); } function buildNoteToolbar(toolbar) { toolbar.innerHTML = `
`; } /* ========================================================= 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: '\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; rail.appendChild(btn); btn.addEventListener('click', () => { if (action === 'search') { if (typeof window.openSearchModal === 'function') { window.openSearchModal(); } else { document.getElementById('search-box')?.focus(); } return; } const isExpanded = sidebar.classList.contains('expanded'); const samePanel = sidebar.dataset.activePanel === panel; if (isExpanded && samePanel) { closeSidebar(); } else { sidebar.classList.add('expanded'); sidebar.dataset.activePanel = panel; sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); showSidebarPanel(panel); } }); }); let backdrop = document.getElementById('sidebar-backdrop'); if (!backdrop) { backdrop = el('div', { id: 'sidebar-backdrop' }); document.body.appendChild(backdrop); } backdrop.addEventListener('click', closeSidebar); initSidebarToggle(); } function showSidebarPanel(panel) { const body = document.getElementById('sidebar-body'); if (!body) return; body.innerHTML = ''; const panelEl = el('div', { class: 'sidebar-panel', 'data-panel': panel }); panelEl.innerHTML = ` `; body.appendChild(panelEl); panelEl.querySelector('.sidebar-close-btn').addEventListener('click', closeSidebar); 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 = ''; 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 = ` ${escHtml(moc.title)} ${moc.pinned ? 'start' : ''} `; 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 = ''; return; } const list = el('div', { class: 'recent-list' }); recent.forEach(item => { const row = el('button', { class: 'recent-item', type: 'button' }); row.innerHTML = ` ${escHtml(item.title)} ${formatRelativeTime(item.visitedAt)} `; row.addEventListener('click', () => { closeSidebar(); openNote(item.pathname, item.title); }); list.appendChild(row); }); container.appendChild(list); } function buildBrowsePanel(container) { container.innerHTML = `
`; 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; const data = await fetchSidebarData(); let treeData = Array.isArray(data.tree) ? data.tree : null; if (treeData) { renderTree(treeData, container, currentPath); return; } const groups = await buildGroupsFromSearchIndex(); if (!groups?.length) { container.innerHTML = ''; return; } const fallbackTree = groups.map(g => ({ label: g.label, children: [], files: g.files })); renderTree(fallbackTree, 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 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; 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, folderKey); } folderEl.addEventListener('click', () => { 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'); if (!res.ok) return null; const raw = await res.json(); const all = Array.isArray(raw) ? raw : raw.entries || raw.notes || []; const files = all .filter(e => e && (e.url || e.path || e.pathname)) .map(e => ({ title: e.title || filenameToTitle(e.url || e.path || e.pathname), url: e.url || e.path || e.pathname, })) .sort((a, b) => a.title.localeCompare(b.title)); return files.length ? [{ label: 'all notes', files }] : null; } catch (_) { return null; } } 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(titleSpan); fileEl.title = title; fileEl.addEventListener('click', () => { closeSidebar(); openNote(pathname, title); }); return fileEl; } /* ========================================================= NOTE SYSTEM ========================================================= */ const noteHistory = []; let historyIndex = -1; let activeNoteId = null; function makeHistoryState(entry, hash = '') { return { notePathname: entry.pathname, noteLabel: entry.label, noteHash: hash || '', isRoot: !!entry.isRoot, }; } function historyUrlFor(entry, hash = '') { if (entry.isRoot) { return location.pathname + location.search; } return entry.pathname + (hash || ''); } function readHashState() { const hash = location.hash.slice(1); const state = { active: null }; if (!hash) return state; hash.split('&').forEach(part => { const [key, val] = part.split('='); if (!val) return; if (key === 'active') state.active = decodeURIComponent(val); }); return state; } function writeHashState() { const current = noteHistory[historyIndex]; if (!current || current.isRoot) { history.replaceState(null, '', location.pathname + location.search); return; } const newHash = '#active=' + encodeURIComponent(current.pathname); history.replaceState(null, '', location.pathname + location.search + newHash); } function initNoteSystem() { const rootPane = document.querySelector(".tab-content[data-tab-id='tab-root']"); if (!rootPane) return; const titleEl = rootPane.querySelector('.title-section .title, h1.title, h1'); const label = titleEl?.textContent.trim() || document.title || 'Home'; const rootEntry = { id: 'tab-root', label, pathname: location.pathname, paneEl: rootPane, isRoot: true }; noteHistory.push(rootEntry); historyIndex = 0; activeNoteId = 'tab-root'; updateBreadcrumb(); updateToolbar(); initMediaFixes(rootPane); if (window._gallerySetupVideo) window._gallerySetupVideo(rootPane); if (window._gallerySetupAudio) window._gallerySetupAudio(rootPane); // Register the initial entry with browser history history.replaceState( makeHistoryState(rootEntry), '', historyUrlFor(rootEntry) ); // Intercept internal .html link clicks only document.addEventListener('click', e => { const link = e.target.closest('a'); if (!link) return; const href = link.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return; let url; try { url = new URL(href, location.href); } catch { return; } if (url.origin !== location.origin) return; // Only intercept .html pages — let assets, PDFs, etc. through if (!url.pathname.endsWith('.html')) return; e.preventDefault(); const pathname = normalisePathname(url.pathname); const hash = url.hash || ''; const label = link.textContent.trim() || filenameToTitle(pathname); openNote(pathname, label, hash); }); // Toolbar buttons document.getElementById('tb-back')?.addEventListener('click', goBack); 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', () => { document.getElementById('backlink-panel')?.classList.remove('open'); document.getElementById('tb-backlinks')?.classList.remove('active'); }); window.addEventListener('popstate', async (e) => { const state = e.state; // No state: fall back to root if (!state || !state.notePathname) { if (noteHistory[0]) activateNote(noteHistory[0].id, true); return; } const pathname = normalisePathname(state.notePathname); const label = state.noteLabel || filenameToTitle(pathname); const hash = state.noteHash || ''; const existing = noteHistory.find(n => n.pathname === pathname); if (existing) { activateNote(existing.id, true); if (hash) scrollToHash(existing.paneEl, hash); return; } await openNote(pathname, label, hash, true, false); }); } async function openNote(pathname, label, hash = '', silent = false, pushBrowserState = true) { console.log('Opening note:', pathname, { label, hash, silent }); // Normalise pathname = normalisePathname(pathname); // Already at this note? const current = noteHistory[historyIndex]; if (current?.pathname === pathname) { if (hash) scrollToHash(current.paneEl, hash); return; } // Already exists in history? const existing = noteHistory.find(n => n.pathname === pathname); if (existing) { historyIndex = noteHistory.indexOf(existing); activateNote(existing.id, true); if (hash) scrollToHash(existing.paneEl, hash); if (pushBrowserState) { history.pushState( makeHistoryState(existing, hash), '', historyUrlFor(existing, hash) ); } return; } // Show loading indicator const contentArea = document.getElementById('content-area'); const loadingPane = el('div', { class: 'tab-content active', 'data-loading': '1' }); loadingPane.innerHTML = '
Loading\u2026
'; contentArea?.appendChild(loadingPane); // Hide all other panes while loading noteHistory.forEach(n => n.paneEl.classList.remove('active')); let contentEl; let resolvedLabel = label || filenameToTitle(pathname); try { const res = await fetch(pathname); if (!res.ok) throw new Error(`HTTP ${res.status}`); const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); const titleEl = doc.querySelector('.title-section .title, h1.title, h1'); if (titleEl) resolvedLabel = titleEl.textContent.trim(); // Prefer #content, else fall back gracefully contentEl = doc.getElementById('content') || doc.querySelector('.stack-pane') || (() => { const d = document.createElement('div'); d.id = 'content'; d.innerHTML = '

Note loaded but content element not found.

'; return d; })(); // Decode mermaid entities contentEl.querySelectorAll('.mermaid').forEach(m => { m.innerHTML = m.innerHTML .replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&'); }); fixRelativeSrcs(contentEl, pathname); } catch (err) { console.error('Note fetch error:', err); contentEl = document.createElement('div'); contentEl.id = 'content'; contentEl.innerHTML = `

` + `Could not load ${escHtml(pathname)}: ${escHtml(err.message)}

`; } // Remove the temporary loading pane loadingPane.remove(); const id = 'tab-' + Date.now(); const paneEl = el('div', { class: 'tab-content', 'data-tab-id': id }); paneEl.appendChild(contentEl); contentArea?.appendChild(paneEl); const entry = { id, label: resolvedLabel, pathname, paneEl, isRoot: false }; // Truncate forward history when branching if (historyIndex < noteHistory.length - 1) { noteHistory.slice(historyIndex + 1).forEach(n => { if (!n.isRoot) n.paneEl.remove(); }); noteHistory.splice(historyIndex + 1); } noteHistory.push(entry); historyIndex = noteHistory.length - 1; activateNote(id, true); if (hash) scrollToHash(paneEl, hash); if (pushBrowserState) { history.pushState( makeHistoryState(entry, hash), '', historyUrlFor(entry, hash) ); } // Run mermaid on newly loaded diagrams if (typeof mermaid !== 'undefined') { const diagrams = paneEl.querySelectorAll('.mermaid:not([data-processed])'); if (diagrams.length) mermaid.run({ nodes: diagrams }); } initCopyButtonsIn(paneEl); initFootnoteSidenotesIn(paneEl); initMediaFixes(paneEl); initLinkPreviewsIn(paneEl); const trySetup = () => { if (window._gallerySetupVideo) window._gallerySetupVideo(paneEl); if (window._gallerySetupAudio) window._gallerySetupAudio(paneEl); }; if (window._gallerySetupVideo) { trySetup(); } else { setTimeout(trySetup, 200); } updateSidebarActive(pathname); } function activateNote(id, skipBrowserWrite = true) { activeNoteId = id; const idx = noteHistory.findIndex(n => n.id === id); if (idx !== -1) historyIndex = idx; noteHistory.forEach(n => n.paneEl.classList.toggle('active', n.id === id)); const area = document.getElementById('content-area'); if (area) area.scrollTop = 0; const current = noteHistory[historyIndex]; if (current) { trackRecentNote(current.pathname, current.label); } updateBreadcrumb(); updateToolbar(); // Refresh backlink panel if open const panel = document.getElementById('backlink-panel'); if (panel?.classList.contains('open')) refreshBacklinkPanel(); updateSidebarActive(noteHistory[historyIndex]?.pathname); } function goBack() { window.history.back(); } 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; trail.innerHTML = ''; const start = Math.max(0, historyIndex - 2); const slice = noteHistory.slice(start, historyIndex + 1); slice.forEach((entry, i) => { if (i > 0) { const sep = el('span', { class: 'crumb-sep', 'aria-hidden': 'true' }); sep.textContent = '/'; trail.appendChild(sep); } const isActive = i === slice.length - 1; const crumb = el('span', { class: 'crumb' + (isActive ? ' active' : '') }); if (isActive) crumb.setAttribute('aria-current', 'page'); crumb.textContent = entry.label; crumb.title = entry.pathname; if (!isActive) { const targetIdx = start + i; crumb.addEventListener('click', () => { historyIndex = targetIdx; activateNote(entry.id); }); } trail.appendChild(crumb); }); } function updateToolbar() { const backBtn = document.getElementById('tb-back'); if (backBtn) { const canGoBack = historyIndex > 0; backBtn.disabled = !canGoBack; backBtn.style.opacity = canGoBack ? '1' : '0.35'; } const fwdBtn = document.getElementById('tb-forward'); if (fwdBtn) { const canGoFwd = historyIndex < noteHistory.length - 1; fwdBtn.disabled = !canGoFwd; fwdBtn.style.opacity = canGoFwd ? '1' : '0.35'; } const pos = document.getElementById('note-graph-pos'); if (pos) { const current = noteHistory[historyIndex]; if (current) { const name = current.pathname.split('/').pop().replace('.html', ''); // Strip the timestamp prefix from Org-Roam filenames const clean = name.replace(/^\d{14}-/, ''); pos.textContent = clean.length > 24 ? clean.slice(0, 22) + '\u2026' : clean; } } } function toggleTOC() { const active = noteHistory[historyIndex]; if (!active) return; const toc = active.paneEl.querySelector('#table-of-contents'); if (!toc) return; const hidden = toc.style.display === 'none'; toc.style.display = hidden ? '' : 'none'; document.getElementById('tb-toc')?.classList.toggle('active', hidden); } /* ========================================================= BACKLINK PANEL ========================================================= */ function initBacklinkPanel() { // Panel built in rearrangeDOM; nothing else needed here } function toggleBacklinkPanel() { const panel = document.getElementById('backlink-panel'); const btn = document.getElementById('tb-backlinks'); if (!panel) return; const isOpen = panel.classList.toggle('open'); btn?.classList.toggle('active', isOpen); if (isOpen) refreshBacklinkPanel(); } async function refreshBacklinkPanel() { const panel = document.getElementById('backlink-panel'); if (!panel?.classList.contains('open')) return; const list = document.getElementById('bp-list'); const countEl = document.getElementById('bp-count'); const current = noteHistory[historyIndex]; if (!list || !current) return; list.innerHTML = '

Loading\u2026

'; if (countEl) countEl.textContent = '\u2026'; try { const res = await fetch('/search-index.json'); if (!res.ok) throw new Error('no index'); const raw = await res.json(); const all = Array.isArray(raw) ? raw : raw.entries || raw.notes || []; // Match by both the bare filename and the full pathname const currentFile = current.pathname.split('/').pop().replace(/\.html$/, ''); const currentPath = normalisePathname(decodeURIComponent(current.pathname)); const backlinks = all.filter(e => { if (!e) return false; const entryUrl = normalisePathname(decodeURIComponent(e.url || '')); if (entryUrl === currentPath) return false; const links = (e.links || []).map(l => normalisePathname(decodeURIComponent(l)) ); return links.some(l => l === currentPath); }); if (countEl) countEl.textContent = String(backlinks.length); if (!backlinks.length) { list.innerHTML = '

No backlinks found for this note.

'; return; } console.log("Have backlinks for",{ currentPath, currentFile, sampleEntry: all[0] }); list.innerHTML = ''; backlinks.forEach(bl => { const pathname = normalisePathname(bl.url || bl.path || bl.pathname || ''); const title = bl.title || filenameToTitle(pathname); const excerpt = (bl.content || bl.body || bl.text || '').slice(0, 140).trim(); const item = el('div', { class: 'bp-item' }); const titleDiv = el('div', { class: 'bp-item-title' }); titleDiv.textContent = title; item.appendChild(titleDiv); if (excerpt) { const excDiv = el('div', { class: 'bp-item-excerpt' }); excDiv.textContent = excerpt + '\u2026'; item.appendChild(excDiv); } item.addEventListener('click', () => openNote(pathname, title)); list.appendChild(item); }); } catch (_) { list.innerHTML = '

Could not load backlinks.

'; if (countEl) countEl.textContent = '!'; } } /* ========================================================= LINK HOVER PREVIEWS ========================================================= */ let previewTimer = null; const previewCache = {}; function initLinkPreviews() { if (!document.getElementById('link-preview')) { const card = el('div', { id: 'link-preview', role: 'tooltip', 'aria-hidden': 'true' }); card.innerHTML = `
Note title
Loading\u2026
`; document.body.appendChild(card); } initLinkPreviewsIn(document); } function initLinkPreviewsIn(root) { // Only attach previews to internal .html links, not external or asset links root.querySelectorAll('a[href*=".html"]').forEach(link => { if (link.dataset.previewInited) return; // Skip links to external origins try { const url = new URL(link.getAttribute('href'), location.href); if (url.origin !== location.origin) return; } catch { return; } link.dataset.previewInited = '1'; link.addEventListener('mouseenter', e => { clearTimeout(previewTimer); previewTimer = setTimeout(() => showPreview(link, e), 300); }); link.addEventListener('mouseleave', () => { clearTimeout(previewTimer); hidePreview(); }); link.addEventListener('mousemove', e => { const card = document.getElementById('link-preview'); if (card?.classList.contains('visible')) positionPreview(card, e); }); }); } async function showPreview(link, evt) { const card = document.getElementById('link-preview'); if (!card) return; const href = link.getAttribute('href'); let url; try { url = new URL(href, location.href); } catch { return; } if (url.origin !== location.origin) return; const pathname = normalisePathname(url.pathname); const titleEl = document.getElementById('preview-title'); const bodyEl = document.getElementById('preview-body'); titleEl.textContent = link.textContent.trim() || filenameToTitle(pathname); bodyEl.textContent = 'Loading\u2026'; positionPreview(card, evt); card.classList.add('visible'); card.setAttribute('aria-hidden', 'false'); if (previewCache[pathname]) { const { title, body } = previewCache[pathname]; titleEl.textContent = title; bodyEl.textContent = body || 'No preview available.'; return; } try { const res = await fetch(pathname); if (!res.ok) throw new Error('fetch failed'); const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); const h1 = doc.querySelector('.title-section .title, h1.title, h1'); const paras = [...doc.querySelectorAll('#content p')]; const title = h1?.textContent.trim() || filenameToTitle(pathname); const body = paras.slice(0, 3).map(p => p.textContent.trim()).join(' ').slice(0, 280); previewCache[pathname] = { title, body }; if (card.classList.contains('visible')) { titleEl.textContent = title; bodyEl.textContent = body || 'No preview available.'; } } catch (_) { if (card.classList.contains('visible')) bodyEl.textContent = 'Preview unavailable.'; } } function positionPreview(card, evt) { const margin = 14; const cw = card.offsetWidth || 300; const ch = card.offsetHeight || 200; let x = evt.clientX + margin; let y = evt.clientY + margin; if (x + cw > window.innerWidth - margin) x = evt.clientX - cw - margin; if (y + ch > window.innerHeight - margin) y = evt.clientY - ch - margin; card.style.left = x + 'px'; card.style.top = y + 'px'; } function hidePreview() { const card = document.getElementById('link-preview'); card?.classList.remove('visible'); card?.setAttribute('aria-hidden', 'true'); } /* ========================================================= READING PROGRESS BAR ========================================================= */ function initReadingProgress() { const bar = document.getElementById('reading-progress'); const area = document.getElementById('content-area'); if (!bar || !area) return; area.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = area; const pct = scrollHeight <= clientHeight ? 0 : (scrollTop / (scrollHeight - clientHeight)) * 100; bar.style.width = pct + '%'; }, { passive: true }); } /* ========================================================= SEARCH RESULTS HOOK ========================================================= */ function hookSearchResults() { const resultsBox = document.getElementById('search-results'); const searchBox = document.getElementById('search-box'); if (!resultsBox) return; resultsBox.addEventListener('mousedown', e => { let item = e.target; while (item && item !== resultsBox) { const url = item.dataset.url || item.dataset.href || item.dataset.path || item.querySelector?.('a')?.getAttribute('href') || (item.tagName === 'A' ? item.getAttribute('href') : null); if (url) { e.preventDefault(); e.stopPropagation(); resultsBox.innerHTML = ''; if (searchBox) searchBox.value = ''; window.closeSearchModal?.(); let resolved; try { resolved = new URL(url, location.href); } catch { return; } if (resolved.origin !== location.origin) { window.open(url, '_blank'); return; } if (!resolved.pathname.endsWith('.html')) return; const label = item.textContent.trim() || filenameToTitle(resolved.pathname); openNote(resolved.pathname, label, resolved.hash); return; } item = item.parentElement; } }); if (searchBox) { searchBox.addEventListener('keydown', e => { if (e.key !== 'Enter') return; const active = resultsBox.querySelector('.active, li:first-child, div:first-child'); if (!active) return; const url = active.dataset.url || active.dataset.href || active.querySelector?.('a')?.getAttribute('href'); if (!url) return; resultsBox.innerHTML = ''; searchBox.value = ''; window.closeSearchModal?.(); let resolved; try { resolved = new URL(url, location.href); } catch { return; } openNote(resolved.pathname, active.textContent.trim(), resolved.hash); }); } } /* ========================================================= SIDEBAR TOGGLE ========================================================= */ function initSidebarToggle() { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; // Keyboard shortcut: Ctrl+\ toggles sidebar document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === '\\') { e.preventDefault(); const nowExpanded = sidebar.classList.toggle('expanded'); if (nowExpanded && !sidebar.dataset.activePanel) { sidebar.dataset.activePanel = 'atlas'; showSidebarPanel('atlas'); sidebar.querySelector('#rail-atlas')?.classList.add('active'); } if (!nowExpanded) { sidebar.dataset.activePanel = ''; sidebar.querySelectorAll('.rail-btn').forEach(b => b.classList.remove('active')); } } }); // Touch swipe-to-open let touchStartX = null; document.addEventListener('touchstart', e => { if (e.touches[0].clientX < 20) touchStartX = e.touches[0].clientX; }, { passive: true }); document.addEventListener('touchend', e => { if (touchStartX === null) return; const dx = e.changedTouches[0].clientX - touchStartX; touchStartX = null; if (dx > 40) { sidebar.classList.add('expanded', 'mobile-open'); document.getElementById('sidebar-backdrop')?.classList.add('visible'); } }, { passive: true }); sidebar.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true }); sidebar.addEventListener('touchend', e => { if (touchStartX === null) return; const dx = e.changedTouches[0].clientX - touchStartX; touchStartX = null; if (dx < -40) { sidebar.classList.remove('expanded', 'mobile-open'); document.getElementById('sidebar-backdrop')?.classList.remove('visible'); } }, { passive: true }); } /* ========================================================= SIDEBAR ACTIVE STATE ========================================================= */ function updateSidebarActive(pathname) { document.querySelectorAll('.tree-file').forEach(f => { f.classList.toggle('active', f.dataset.pathname === pathname); }); } /* ========================================================= MEDIA FIXES ========================================================= */ function initMediaFixes(root) { root.querySelectorAll('audio').forEach(audio => { audio.controls = true; if (!audio.closest('.audio-player')) { const wrapper = document.createElement('div'); wrapper.className = 'audio-player'; const src = audio.src || audio.querySelector?.('source')?.src || ''; const labelText = audio.dataset.label || audio.title || src.split('/').pop().replace(/\.[^.]+$/, '') || 'audio'; const labelEl = document.createElement('div'); labelEl.className = 'audio-label'; labelEl.textContent = labelText; audio.parentNode.insertBefore(wrapper, audio); wrapper.append(labelEl, audio); } }); root.querySelectorAll('video').forEach(video => { video.controls = true; video.style.cssText = 'max-width:100%;width:100%;height:auto;display:block;border-radius:6px;'; if (!video.closest('.video-container')) { const wrapper = document.createElement('div'); wrapper.className = 'video-container'; video.parentNode.insertBefore(wrapper, video); wrapper.appendChild(video); } if (!video.currentSrc) video.load(); }); } function fixRelativeSrcs(root, fromPathname) { const base = new URL(fromPathname, location.href).href.split('/').slice(0, -1).join('/') + '/'; const fix = (elmt, attr) => { const val = elmt.getAttribute(attr); if (!val || val.startsWith('http') || val.startsWith('//') || val.startsWith('data:') || val.startsWith('#') || val.startsWith('/')) return; try { elmt.setAttribute(attr, new URL(val, base).href); } catch {} }; root.querySelectorAll('img').forEach(e => fix(e, 'src')); root.querySelectorAll('video').forEach(e => fix(e, 'src')); root.querySelectorAll('audio').forEach(e => fix(e, 'src')); root.querySelectorAll('source').forEach(e => fix(e, 'src')); root.querySelectorAll('a').forEach(e => fix(e, 'href')); } /* ========================================================= COPY BUTTONS ========================================================= */ function initCopyButtons() { initCopyButtonsIn(document); } function initCopyButtonsIn(root) { root.querySelectorAll('pre.src').forEach(block => { if (block.querySelector('.copy-btn')) return; block.style.position = 'relative'; const btn = document.createElement('button'); btn.className = 'copy-btn'; btn.textContent = 'copy'; block.appendChild(btn); btn.addEventListener('click', async () => { const text = block.innerText.replace(btn.innerText, '').trim(); try { await navigator.clipboard.writeText(text); btn.textContent = 'copied'; } catch { btn.textContent = 'failed'; } setTimeout(() => { btn.textContent = 'copy'; }, 1600); }); }); } /* ========================================================= FOOTNOTE SIDENOTES ========================================================= */ function initFootnoteSidenotes() { initFootnoteSidenotesIn(document); } function initFootnoteSidenotesIn(root) { root.querySelectorAll('a.footref[href^="#fn"]').forEach(ref => { const sup = ref.closest('sup') || ref; if (sup.nextElementSibling?.classList.contains('footnote-sidenote')) return; const targetId = ref.getAttribute('href').slice(1); const anchor = document.getElementById(targetId) || root.querySelector?.(`#${CSS.escape(targetId)}`); if (!anchor) return; const footdef = anchor.closest('.footdef') || anchor.parentElement; if (!footdef) return; let paras = footdef.querySelectorAll('p.footpara'); if (!paras.length) paras = footdef.querySelectorAll('.footpara'); let parts = []; if (paras.length) { const seen = new Set(); parts = [...paras].map(p => { const txt = p.textContent.trim().replace(/\s+/g, ' '); if (seen.has(txt)) return ''; seen.add(txt); return p.innerHTML.trim(); }).filter(Boolean); } if (!parts.length) { const clone = footdef.cloneNode(true); clone.querySelectorAll('sup.footnum, a[role="doc-backlink"]').forEach(n => n.remove()); parts = [clone.innerHTML.trim()]; } const sidenote = document.createElement('span'); sidenote.className = 'sidenote footnote-sidenote'; sidenote.dataset.fn = ref.textContent.trim(); sidenote.innerHTML = parts.join(' '); sup.insertAdjacentElement('afterend', sidenote); }); } /* ========================================================= THEME TOGGLE ========================================================= */ function initThemeToggle() { const root = document.documentElement; const key = 'theme'; const saved = localStorage.getItem(key); const initial = (saved === 'dark' || saved === 'light') ? saved : 'dark'; root.setAttribute('data-theme', initial); const btn = document.getElementById('theme-toggle'); if (!btn) return; const updateIcon = theme => { // Moon for dark mode, sun for light mode btn.textContent = theme === 'dark' ? '\u263D' : '\u2600'; btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'); }; updateIcon(initial); btn.addEventListener('click', () => { const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; root.setAttribute('data-theme', next); localStorage.setItem(key, next); updateIcon(next); }); } /* ========================================================= COUNTDOWNS ========================================================= */ function initCountdowns() { const els = document.querySelectorAll('time.countdown'); if (!els.length) return; const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; const render = el => { const target = new Date(el.getAttribute('datetime')); const label = el.dataset.label || ''; if (isNaN(target)) { el.textContent = ''; return; } let diff = target - new Date(); if (diff <= 0) { el.textContent = `${label ? label + ' ' : ''}today`; el.classList.add('expired'); return; } const d = Math.floor(diff / 86400000); diff %= 86400000; const h = Math.floor(diff / 3600000); diff %= 3600000; const m = Math.floor(diff / 60000); diff %= 60000; const s = Math.floor(diff / 1000); const parts = []; if (d) parts.push(plural(d, 'day')); parts.push(`${h}h ${m}m ${s}s`); el.textContent = `${label ? label + ' in: ' : ''}${parts.join(' ')}`; }; const tick = () => els.forEach(render); tick(); setInterval(tick, 1000); } /* ========================================================= TOC HIGHLIGHTING ========================================================= */ function initTOCHighlighting() { // Scoped to the currently active pane const getActiveTOC = () => { const active = noteHistory[historyIndex]; if (!active) return null; return active.paneEl.querySelector('#text-table-of-contents'); }; const toc = getActiveTOC() || document.querySelector('#text-table-of-contents'); if (!toc) return; const links = [...toc.querySelectorAll('a[href^="#"]')]; if (!links.length) return; const linkById = new Map(); links.forEach(a => { const id = decodeURIComponent(a.hash.slice(1)); const elmt = document.getElementById(id); if (elmt) linkById.set(id, a); }); const headings = [...document.querySelectorAll('h2[id], h3[id], h4[id]')] .filter(h => linkById.has(h.id)); const setActive = id => { links.forEach(a => { const active = a.hash === `#${id}`; a.classList.toggle('is-active', active); if (active) a.setAttribute('aria-current', 'true'); else a.removeAttribute('aria-current'); }); }; const headerOffset = 6.5 * parseFloat(getComputedStyle(document.documentElement).fontSize); const visible = new Map(); const observer = new IntersectionObserver(entries => { entries.forEach(entry => { const id = entry.target.id; if (entry.isIntersecting) { visible.set(id, entry.target.getBoundingClientRect().top - headerOffset); } else { visible.delete(id); } }); if (visible.size) { const [id] = [...visible.entries()].sort((a, b) => Math.abs(a[1]) - Math.abs(b[1]))[0]; setActive(id); } }, { rootMargin: `-${headerOffset}px 0px -70% 0px`, threshold: [0, 0.01, 0.1] }); headings.forEach(h => observer.observe(h)); toc.addEventListener('click', e => { const a = e.target.closest('a[href^="#"]'); if (!a) return; const id = decodeURIComponent(a.hash.slice(1)); const elmt = document.getElementById(id); if (!elmt) return; e.preventDefault(); elmt.scrollIntoView({ behavior: 'smooth', block: 'start' }); elmt.setAttribute('tabindex', '-1'); elmt.focus({ preventScroll: true }); history.pushState(null, '', `#${id}`); }); } /* ========================================================= HELPERS ========================================================= */ function scrollToHash(paneEl, hash) { if (!hash) return; requestAnimationFrame(() => { const id = hash.replace(/^#/, ''); const target = paneEl.querySelector(`#${CSS.escape(id)}`); const area = document.getElementById('content-area'); if (target && area) area.scrollTop = target.offsetTop; }); } function el(tag, attrs = {}) { const e = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => { if (v !== undefined) e.setAttribute(k, v); }); return e; } function escHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function filenameToTitle(pathname) { const base = (pathname || '').split('/').pop().replace(/\.html$/, ''); // Strip Org-Roam timestamp prefix (14 digits) const clean = base.replace(/^\d{14}-/, ''); return clean.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } function normalisePathname(p) { if (!p) return ''; if (p.startsWith('/')) return p; try { return new URL(p, location.href).pathname; } catch { return '/' + 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 = `

Zettelgarten

A private library of notes, maps, and wandering thoughts.

Start here

`; 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 = ` ${escHtml(moc.title)} ${moc.pinned ? 'start' : ''} `; 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 = ` @keyframes pane-out { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(16px); } } .note-loading { display: flex; align-items: center; justify-content: center; min-height: 40vh; color: var(--fg-faint); font-family: var(--font-mono); font-size: 0.85rem; letter-spacing: 0.06em; opacity: 0.6; } `; document.head.appendChild(_s);