const state = { archives: [], archiveId: null, entries: [], selectedEntryUid: null, selectedEntry: null, tagFilter: null, }; let selectSeq = 0; const archiveSwitcher = document.querySelector("#archive-switcher"); const entriesBody = document.querySelector("#entries-body"); const runsBody = document.querySelector("#runs-body"); const contextBody = document.querySelector("#context-body"); const navButtons = document.querySelectorAll(".nav-link"); const searchInput = document.querySelector("#search"); const resultCount = document.querySelector("#result-count"); const adminArchives = document.querySelector("#admin-archives"); const tagTree = document.querySelector("#tag-tree"); const entryTagsEl = document.querySelector("#entry-tags"); const assignTagForm = document.querySelector("#assign-tag-form"); const assignTagInput = document.querySelector("#assign-tag-input"); const assignTagBtn = document.querySelector("#assign-tag-btn"); const captureButton = document.querySelector('.capture-button'); const captureDialog = document.querySelector('#capture-dialog'); const captureLocatorInput = document.querySelector('#capture-locator'); const captureSubmitBtn = document.querySelector('#capture-submit-btn'); const captureCancelBtn = document.querySelector('#capture-cancel-btn'); const captureError = document.querySelector('#capture-error'); function formatBytes(bytes) { if (!bytes) return "0 B"; const units = ["B", "KB", "MB", "GB"]; let size = bytes; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; } function valueText(value) { return value ?? ""; } const SOURCE_ICONS = { youtube: ``, x: ``, instagram: ``, facebook: ``, tiktok: ``, reddit: ``, snapchat: ``, local: ``, web: ``, other: `?`, }; function sourceIcon(kind) { const svg = SOURCE_ICONS[kind] || SOURCE_ICONS.other; const el = document.createElement("span"); el.className = "source-icon"; el.innerHTML = svg; return el; } function formatTimestamp(value) { if (!value) return ""; const d = new Date(value); if (isNaN(d)) return value; const pad = (n) => String(n).padStart(2, "0"); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } async function getJson(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } return response.json(); } function appendCell(row, text, className) { const cell = document.createElement("div"); if (className) cell.className = className; cell.textContent = text; row.append(cell); return cell; } function renderArchives() { archiveSwitcher.innerHTML = ""; for (const archive of state.archives) { const option = document.createElement("option"); option.value = archive.id; option.textContent = archive.label; archiveSwitcher.append(option); } archiveSwitcher.value = state.archiveId ?? ""; adminArchives.innerHTML = ""; for (const archive of state.archives) { const item = document.createElement("div"); item.className = "admin-archive"; const label = document.createElement("strong"); label.textContent = archive.label; const path = document.createElement("div"); path.className = "muted"; path.textContent = archive.archive_path; item.append(label, path); adminArchives.append(item); } } function renderEntries() { entriesBody.innerHTML = ""; if (state.entries.length === 0 && searchInput.value.trim()) { resultCount.textContent = "No results."; } else { resultCount.textContent = `${state.entries.length} entries`; } if (state.tagFilter) { const badge = document.createElement("button"); badge.className = "tag-filter-badge"; badge.textContent = `× ${state.tagFilter}`; badge.addEventListener("click", () => { state.tagFilter = null; if (state.archiveId) loadEntries(searchInput.value); }); resultCount.appendChild(badge); } for (const entry of state.entries) { const row = document.createElement("div"); row.tabIndex = 0; row.dataset.entryUid = entry.entry_uid; if (entry.entry_uid === state.selectedEntryUid) { row.classList.add("is-selected"); } appendCell(row, formatTimestamp(entry.archived_at), "col-added"); const titleCell = appendCell(row, "", "col-title"); titleCell.append(sourceIcon(entry.source_kind)); const title = document.createElement("span"); title.className = "entry-title"; title.textContent = valueText(entry.title) || valueText(entry.entry_uid); titleCell.append(title); const typeCell = appendCell(row, "", "col-type"); const type = document.createElement("span"); type.className = "type-pill"; type.textContent = valueText(entry.entity_kind); typeCell.append(type); appendCell(row, formatBytes(entry.total_artifact_bytes), "col-size"); appendCell(row, valueText(entry.original_url), "url-cell col-url"); row.addEventListener("click", () => selectEntry(entry)); row.addEventListener("keydown", (event) => { if (event.key === "Enter") selectEntry(entry); }); entriesBody.append(row); } } function renderContextDetail(detail) { contextBody.innerHTML = ""; // Title const titleEl = document.createElement("strong"); titleEl.className = "rail-entry-title"; titleEl.textContent = valueText(detail.summary.title) || valueText(detail.summary.entry_uid); contextBody.append(titleEl); // Metadata section const metaSection = document.createElement("div"); metaSection.className = "rail-section"; if (detail.summary.original_url) { const urlRow = document.createElement("div"); urlRow.className = "rail-item"; const urlLabel = document.createElement("span"); urlLabel.className = "rail-label"; urlLabel.textContent = "Original URL"; const urlLink = document.createElement("a"); urlLink.href = detail.summary.original_url; urlLink.target = "_blank"; urlLink.rel = "noopener noreferrer"; urlLink.className = "rail-url-link"; urlLink.textContent = detail.summary.original_url; urlRow.append(urlLabel, document.createTextNode(": "), urlLink); metaSection.append(urlRow); } const metaFields = [ ["Added", formatTimestamp(detail.summary.archived_at)], ["Source", detail.summary.source_kind], ["Type", detail.summary.entity_kind], ["Visibility", detail.summary.visibility], ["Structured root", detail.structured_root_relpath], ]; for (const [label, value] of metaFields) { const item = document.createElement("div"); item.className = "rail-item"; const labelEl = document.createElement("span"); labelEl.className = "rail-label"; labelEl.textContent = label; item.append(labelEl, document.createTextNode(`: ${valueText(value)}`)); metaSection.append(item); } contextBody.append(metaSection); // Artifacts section if (detail.artifacts.length > 0) { const artifactsSection = document.createElement("div"); artifactsSection.className = "rail-section"; const artifactsHeading = document.createElement("div"); artifactsHeading.className = "rail-section-heading"; artifactsHeading.textContent = `Artifacts (${detail.artifacts.length})`; artifactsSection.append(artifactsHeading); const list = document.createElement("ul"); list.className = "artifact-list"; detail.artifacts.forEach((artifact, index) => { const li = document.createElement("li"); const a = document.createElement("a"); a.href = `/api/archives/${state.archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`; a.target = "_blank"; a.rel = "noopener noreferrer"; a.className = "artifact-link"; const roleName = artifact.artifact_role.replace(/_/g, " "); const size = artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ""; a.textContent = `${roleName}${size}`; li.append(a); list.append(li); }); artifactsSection.append(list); contextBody.append(artifactsSection); } else { const noArtifacts = document.createElement("div"); noArtifacts.className = "rail-item muted"; noArtifacts.textContent = "No artifacts."; contextBody.append(noArtifacts); } } function renderEntryTags(tags, entryUid) { entryTagsEl.innerHTML = ""; if (!tags.length) { entryTagsEl.textContent = "No tags."; return; } for (const tag of tags) { const pill = document.createElement("span"); pill.className = "tag-pill"; pill.textContent = tag.name; pill.title = tag.full_path; const removeBtn = document.createElement("button"); removeBtn.className = "remove-tag"; removeBtn.textContent = "×"; removeBtn.title = `Remove tag ${tag.full_path}`; removeBtn.addEventListener("click", async () => { const resp = await fetch( `/api/archives/${state.archiveId}/entries/${entryUid}/tags/${tag.tag_uid}`, { method: "DELETE" } ); if (!resp.ok) { removeBtn.title = `Remove failed (${resp.status})`; return; } const updated = await getJson( `/api/archives/${state.archiveId}/entries/${entryUid}/tags` ); renderEntryTags(updated, entryUid); loadTagTree(); }); pill.appendChild(removeBtn); entryTagsEl.appendChild(pill); } } async function selectEntry(entry) { const seq = ++selectSeq; state.selectedEntryUid = entry.entry_uid; state.selectedEntry = entry; renderEntries(); const detail = await getJson( `/api/archives/${state.archiveId}/entries/${entry.entry_uid}` ); if (seq !== selectSeq) return; renderContextDetail(detail); entryTagsEl.hidden = false; assignTagForm.hidden = false; entryTagsEl.innerHTML = ""; const tags = await getJson( `/api/archives/${state.archiveId}/entries/${entry.entry_uid}/tags` ); if (seq !== selectSeq) return; renderEntryTags(tags, entry.entry_uid); } async function loadRuns() { const runs = await getJson(`/api/archives/${state.archiveId}/runs`); runsBody.innerHTML = ""; for (const run of runs) { const row = document.createElement("tr"); for (const val of [run.started_at, run.status, run.requested_count, run.completed_count, run.failed_count]) { const td = document.createElement("td"); td.textContent = valueText(String(val ?? "")); row.append(td); } runsBody.append(row); } } async function loadEntries(q = "") { const trimmed = q.trim(); const params = new URLSearchParams(); if (trimmed) params.set("q", trimmed); if (state.tagFilter) params.set("tag", state.tagFilter); const url = trimmed || state.tagFilter ? `/api/archives/${state.archiveId}/entries/search?${params}` : `/api/archives/${state.archiveId}/entries`; searchInput.setAttribute("aria-busy", "true"); try { state.entries = await getJson(url); } catch (err) { resultCount.textContent = "Search failed. Try again."; state.entries = []; } finally { searchInput.removeAttribute("aria-busy"); } renderEntries(); } async function loadTagTree() { if (!state.archiveId) return; const nodes = await getJson(`/api/archives/${state.archiveId}/tags`); tagTree.innerHTML = ""; renderTagTree(nodes, tagTree); } function renderTagTree(nodes, container) { if (!nodes.length) { container.textContent = "No tags yet."; return; } const ul = document.createElement("ul"); ul.className = "tag-tree-list"; for (const node of nodes) { const li = document.createElement("li"); const btn = document.createElement("button"); btn.className = "tag-node-btn"; if (state.tagFilter === node.tag.full_path) btn.classList.add("is-active"); btn.textContent = node.tag.name; btn.title = node.tag.full_path; btn.addEventListener("click", () => { if (state.tagFilter === node.tag.full_path) { state.tagFilter = null; } else { state.tagFilter = node.tag.full_path; } // Switch to archive view and reload switchView("archive"); if (state.archiveId) loadEntries(searchInput.value); }); li.appendChild(btn); if (node.children?.length) { const childContainer = document.createElement("div"); childContainer.className = "tag-children"; renderTagTree(node.children, childContainer); li.appendChild(childContainer); } ul.appendChild(li); } container.appendChild(ul); } async function loadArchives() { state.archives = await getJson("/api/archives"); state.archiveId = state.archives[0]?.id ?? null; renderArchives(); if (state.archiveId) { await loadEntries(); await loadRuns(); loadTagTree(); } else { contextBody.textContent = "No archives mounted."; resultCount.textContent = "0 entries"; } } function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } archiveSwitcher.addEventListener("change", async () => { state.tagFilter = null; state.selectedEntry = null; state.selectedEntryUid = null; entryTagsEl.hidden = true; assignTagForm.hidden = true; state.archiveId = archiveSwitcher.value; await loadEntries(); await loadRuns(); loadTagTree(); }); const debouncedSearch = debounce((q) => { if (state.archiveId) loadEntries(q); }, 300); searchInput.addEventListener("input", () => { debouncedSearch(searchInput.value); }); function switchView(name) { navButtons.forEach(b => b.classList.toggle("is-active", b.dataset.view === name)); document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active")); document.querySelector(`#${name}-view`)?.classList.add("is-active"); } navButtons.forEach((button) => { button.addEventListener("click", () => { switchView(button.dataset.view); if (button.dataset.view === "tags") loadTagTree(); }); }); assignTagBtn.addEventListener("click", async () => { const path = assignTagInput.value.trim(); if (!path || !state.selectedEntry) return; const resp = await fetch( `/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag_path: path }), } ); if (resp.ok) { assignTagInput.setCustomValidity(""); assignTagInput.value = ""; const tags = await getJson( `/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags` ); renderEntryTags(tags, state.selectedEntry.entry_uid); loadTagTree(); } else { assignTagInput.setCustomValidity(`Failed to add tag (${resp.status})`); assignTagInput.reportValidity(); } }); captureButton.addEventListener('click', () => { captureLocatorInput.value = ''; captureError.hidden = true; captureDialog.showModal(); }); captureCancelBtn.addEventListener('click', () => captureDialog.close()); captureSubmitBtn.addEventListener('click', async () => { const locator = captureLocatorInput.value.trim(); if (!locator) { captureError.textContent = 'Enter a locator.'; captureError.hidden = false; return; } captureSubmitBtn.disabled = true; captureSubmitBtn.textContent = 'Capturing\u2026'; captureError.hidden = true; try { const res = await fetch(`/api/archives/${state.archiveId}/captures`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ locator }), }); if (!res.ok) { const msg = await res.text(); throw new Error(msg || `HTTP ${res.status}`); } captureDialog.close(); await Promise.all([loadEntries(searchInput.value), loadRuns()]); } catch (e) { captureError.textContent = e.message; captureError.hidden = false; } finally { captureSubmitBtn.disabled = false; captureSubmitBtn.textContent = 'Capture'; } }); loadArchives().catch((error) => { contextBody.textContent = `Failed to load archives: ${error.message}`; });