import { api } from "./api.js"; import { renderArchive, setArchiveRender } from "./archive.js"; import { navigate, onRouteChange, parseHash } from "./router.js"; import { renderRenames, setRenamesRender } from "./renames.js"; import { renderUploads, setUploadsRender } from "./uploads.js"; import { renderAlbums, renderAnalyze, renderLibrary, renderSafety, renderStats, renderWorkflow, setRender, } from "./views.js"; const root = document.getElementById("app"); // Tiny DOM helper — builds nodes without innerHTML injection. function el(tag, attrs = {}, ...children) { const node = document.createElement(tag); for (const [key, value] of Object.entries(attrs)) { if (value == null || value === false) continue; if (key === "class") node.className = value; else if (key === "dataset") Object.assign(node.dataset, value); else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2).toLowerCase(), value); else node.setAttribute(key, value); } for (const child of children.flat()) { if (child == null || child === false) continue; node.appendChild(typeof child === "string" ? document.createTextNode(child) : child); } return node; } function show(...nodes) { root.replaceChildren(...nodes); } function setActiveNav(view) { document.querySelectorAll("nav a[data-nav]").forEach((a) => { if (a.dataset.nav === view) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); } function errorBanner(message) { return el("div", { class: "alert", role: "alert" }, message); } // ── Inventory view ────────────────────────────────────────────────────────── async function renderInventory(params) { setActiveNav("inventory"); const limit = Number(params.limit || 50); const offset = Number(params.offset || 0); let data; try { data = await api.listAssets({ limit, offset, availability: params.availability || "", q: params.q || "", }); } catch (error) { show(errorBanner(`Failed to load inventory: ${error.message}`)); return; } const search = el("input", { type: "search", placeholder: "Filter by path…", value: params.q || "", "aria-label": "Filter by path", onchange: (e) => navigate("/inventory", { ...params, q: e.target.value, offset: 0 }), }); const availability = el( "select", { "aria-label": "Availability", onchange: (e) => navigate("/inventory", { ...params, availability: e.target.value, offset: 0 }), }, el("option", { value: "" }, "All availability"), ...["active", "archived_offline", "archived_online"].map((v) => el("option", { value: v, selected: params.availability === v ? "selected" : false }, v) ) ); const rows = data.items.map((asset, index) => el( "tr", { "data-testid": "asset-row", "data-asset-id": asset.id, tabindex: "-1", role: "row", onclick: () => selectRow(index), }, el("td", {}, asset.current_path || "(no path)"), el("td", {}, asset.availability_state), el("td", {}, asset.missing ? "missing" : "present"), el("td", { class: "muted" }, asset.byte_size != null ? String(asset.byte_size) : "—") ) ); const tbody = el("tbody", {}, ...rows); let selected = -1; function selectRow(index) { if (index < 0 || index >= rows.length) return; if (selected >= 0) rows[selected].setAttribute("aria-selected", "false"); selected = index; rows[selected].setAttribute("aria-selected", "true"); rows[selected].focus(); } tbody.addEventListener("keydown", (event) => { if (event.key === "ArrowDown") { event.preventDefault(); selectRow(Math.min(selected + 1, rows.length - 1) || 0); } else if (event.key === "ArrowUp") { event.preventDefault(); selectRow(Math.max(selected - 1, 0)); } }); const table = el( "table", { "aria-label": "Inventory" }, el( "thead", {}, el("tr", {}, el("th", {}, "Path"), el("th", {}, "Availability"), el("th", {}, "State"), el("th", {}, "Bytes")) ), tbody ); const start = data.total === 0 ? 0 : offset + 1; const end = Math.min(offset + limit, data.total); const pager = el( "div", { class: "pager" }, el( "button", { "data-testid": "prev-page", disabled: offset === 0 ? "disabled" : false, onclick: () => navigate("/inventory", { ...params, offset: Math.max(offset - limit, 0) }), }, "Previous" ), el("span", { class: "muted", "data-testid": "page-info" }, `${start}–${end} of ${data.total}`), el( "button", { "data-testid": "next-page", disabled: end >= data.total ? "disabled" : false, onclick: () => navigate("/inventory", { ...params, offset: offset + limit }), }, "Next" ) ); show( el("h1", {}, "Inventory"), el("div", { class: "toolbar" }, search, availability), table, pager ); if (rows.length) selectRow(0); } // ── Duplicate cluster list ─────────────────────────────────────────────────── async function renderClusters(params) { setActiveNav("duplicates"); let data; try { data = await api.listClusters({ state: params.state || "", limit: 100 }); } catch (error) { show(errorBanner(`Failed to load clusters: ${error.message}`)); return; } const items = data.items.map((cluster) => el( "div", { class: "card", "data-testid": "cluster-card" }, el( "div", {}, el("a", { class: "link", href: `#/duplicates/${cluster.id}` }, `Cluster ${cluster.id.slice(0, 8)}`), " ", el("span", { class: `badge ${cluster.method}` }, cluster.method), " ", el("span", { class: `badge ${cluster.state}` }, cluster.state) ), el("div", { class: "muted" }, `${cluster.members.length} members · confidence ${cluster.confidence}`) ) ); show( el("h1", {}, "Duplicate clusters"), data.items.length ? el("div", { class: "cluster-grid" }, ...items) : el("p", { class: "muted" }, "No clusters detected.") ); } // ── Duplicate comparison ───────────────────────────────────────────────────── async function renderClusterDetail(id, extra = {}) { setActiveNav("duplicates"); let cluster; try { cluster = await api.getCluster(id); } catch (error) { show(errorBanner(`Failed to load cluster: ${error.message}`)); return; } const zoom = el("input", { type: "range", min: "1", max: "3", step: "0.1", value: "1", "aria-label": "Synchronized zoom", "data-testid": "zoom", }); const images = []; const members = cluster.members.map((member) => { const isCanonical = cluster.canonical_asset_id === member.asset_id; const img = el("img", { src: api.thumbnailUrl(member.asset_id, 512), alt: member.current_path || member.asset_id, loading: "lazy", }); images.push(img); const actions = cluster.state === "decided" || cluster.state === "dismissed" ? [] : [ el( "button", { "data-testid": "make-canonical", onclick: () => decide(cluster, "canonical", member.asset_id), }, "Keep this as canonical" ), ]; return el( "div", { class: `member ${isCanonical ? "canonical" : ""}`, "data-testid": "member" }, img, el( "dl", {}, el("dt", {}, "Path"), el("dd", {}, member.current_path || "—"), el("dt", {}, "Role"), el("dd", {}, member.role), el("dt", {}, "pHash distance"), el("dd", {}, member.distance == null ? "—" : String(member.distance)), el("dt", {}, "Bytes"), el("dd", {}, member.byte_size == null ? "—" : String(member.byte_size)) ), ...actions ); }); zoom.addEventListener("input", () => { const scale = zoom.value; images.forEach((img) => (img.style.transform = `scale(${scale})`)); }); const decisionBar = el( "div", { class: "decision-bar" }, el("span", { "data-testid": "cluster-state", class: `badge ${cluster.state}` }, `state: ${cluster.state}`), el("span", { class: `badge ${cluster.confidence}` }, `confidence: ${cluster.confidence}`), cluster.requires_confirmation ? el("span", { class: "muted" }, "fuzzy — confirmation required") : null, el( "button", { class: "danger", "data-testid": "not-duplicate", onclick: () => decide(cluster, "not_duplicate") }, "Not a duplicate" ), el("button", { "data-testid": "defer", onclick: () => decide(cluster, "deferred") }, "Defer") ); const nodes = [ el("p", {}, el("a", { class: "link", href: "#/duplicates" }, "← All clusters")), el("h1", {}, `Compare cluster ${cluster.id.slice(0, 8)}`), ]; if (extra.conflict) { nodes.push( errorBanner("This cluster changed since you loaded it. Nothing was modified; showing the latest state.") ); } nodes.push(el("div", { class: "toolbar" }, el("label", {}, "Zoom "), zoom)); nodes.push(decisionBar); if (extra.pending) nodes.push(confirmPanel(cluster, extra.pending)); nodes.push(el("div", { class: "cluster-grid" }, ...members)); show(...nodes); } function confirmPanel(cluster, pending) { return el( "div", { class: "confirm", role: "dialog", "aria-label": "Confirm decision", "data-testid": "confirm" }, el("p", {}, `Confirm "${pending.decision.replace("_", " ")}" for this fuzzy cluster?`), el( "div", { class: "decision-bar" }, el( "button", { class: "primary", "data-testid": "confirm-yes", onclick: () => applyDecision(cluster, pending.decision, pending.canonicalId), }, "Confirm" ), el( "button", { "data-testid": "confirm-no", onclick: () => renderClusterDetail(cluster.id) }, "Cancel" ) ) ); } // A fuzzy cluster needs explicit confirmation; exact/pixel apply immediately. function decide(cluster, decision, canonicalId) { if (cluster.requires_confirmation) { renderClusterDetail(cluster.id, { pending: { decision, canonicalId } }); } else { applyDecision(cluster, decision, canonicalId); } } async function applyDecision(cluster, decision, canonicalId) { try { await api.decide(cluster.id, { decision, expected_version: cluster.version, canonical_asset_id: canonicalId || null, }); renderClusterDetail(cluster.id); } catch (error) { if (error.status === 409) { renderClusterDetail(cluster.id, { conflict: true }); } else { show(errorBanner(`Decision failed: ${error.message}`)); } } } // ── Router ─────────────────────────────────────────────────────────────────── function render() { const { path, params } = parseHash(); const clusterMatch = path.match(/^\/duplicates\/(.+)$/); if (path === "/workflow" || path === "/") renderWorkflow(root, params); else if (path === "/inventory") renderInventory(params); else if (path === "/duplicates") renderClusters(params); else if (clusterMatch) renderClusterDetail(clusterMatch[1]); else if (path === "/safety") renderSafety(root, params); else if (path === "/library") renderLibrary(root, params); else if (path === "/analyze") renderAnalyze(root, params); else if (path === "/albums") renderAlbums(root, params); else if (path === "/renames") renderRenames(root, params); else if (path === "/uploads") renderUploads(root, params); else if (path === "/archive") renderArchive(root, params); else if (path === "/stats") renderStats(root, params); else show(errorBanner("Unknown view")); } // Let views re-render the current route after a mutation. setRender(render); setRenamesRender(render); setUploadsRender(render); setArchiveRender(render); onRouteChange(render); render();