// Phase B operational views: Workflow home, Safety review, Library, Analyze, Stats. // Each render function fills the app root from the JSON API — no server-rendered // HTML. Mutating actions are disabled while a job holds the library_write lock and // say why (concept §shared interaction rules: read-only browsing stays available). import { api } from "./api.js"; import { el, errorBanner, setActiveNav } from "./dom.js"; import { navigate } from "./router.js"; import { subscribeJob } from "./events.js"; // Status is never color-only: an icon + label accompany every colored badge. const STATUS_ICON = { complete: "✓", ready: "○", attention: "!", blocked: "●" }; function jobBanner(activeJob) { if (!activeJob) return null; return el( "div", { class: "alert job-running", role: "status", "data-testid": "job-running" }, `A ${activeJob.job_type} job is ${activeJob.state}. Mutating actions are paused; browsing stays available.` ); } // ── Workflow home ──────────────────────────────────────────────────────────── export async function renderWorkflow(root) { setActiveNav("workflow"); let data; try { data = await api.workflow(); } catch (error) { root.replaceChildren(errorBanner(`Failed to load workflow: ${error.message}`)); return; } const active = data.active_job; const cards = data.stages.map((stage) => { const action = stage.action || {}; // A stage whose action starts a job is disabled while any job runs. const disabled = Boolean(active && action.job_type); const button = action.label ? el( "button", { "data-testid": `action-${stage.key}`, disabled: disabled ? "disabled" : false, title: disabled ? "a job is already running" : false, onclick: () => triggerStageAction(stage), }, action.label ) : null; return el( "div", { class: `card stage stage-${stage.status}`, "data-testid": `stage-${stage.key}` }, el( "div", { class: "stage-head" }, el("span", { class: "stage-icon", "aria-hidden": "true" }, STATUS_ICON[stage.status] || "○"), el("strong", {}, stage.title), el("span", { class: `badge ${stage.status}`, "data-testid": `status-${stage.key}` }, stage.status_label) ), el("div", { class: "muted counts", "data-testid": `counts-${stage.key}` }, countsLine(stage.counts)), stage.blocker ? el("div", { class: "blocker", "data-testid": `blocker-${stage.key}` }, `⚠ ${stage.blocker}`) : null, el("div", { class: "muted last-run" }, stage.last_run ? `Last run: ${stage.last_run}` : "Not run yet"), el( "div", { class: "stage-actions" }, button, action.route ? el("a", { class: "link", href: action.route }, "Details →") : null ) ); }); root.replaceChildren( el("h1", {}, "Workflow"), jobBanner(active), el("div", { class: "stepper" }, ...cards) ); } async function triggerStageAction(stage) { const action = stage.action || {}; if (action.job_type === "safety_score") await startJob(api.safetyScoreJob); else if (action.job_type === "analysis") await startJob(api.analysisJob); if (action.route) navigate(action.route.replace(/^#/, "")); } async function startJob(call) { try { await call(); } catch (error) { if (error.status !== 409) alert(`Could not start job: ${error.message}`); } } function countsLine(counts) { return Object.entries(counts || {}) .map(([key, value]) => `${value} ${key}`) .join(" · "); } // ── Safety review ──────────────────────────────────────────────────────────── const SAFETY_FILTERS = ["undecided", "sfw", "nsfw", "deferred"]; export async function renderSafety(root, params) { setActiveNav("safety"); const state = params.state || "undecided"; let data; try { data = await api.safetyQueue({ state }); } catch (error) { root.replaceChildren(errorBanner(`Failed to load safety queue: ${error.message}`)); return; } const tabs = el( "div", { class: "toolbar", role: "tablist" }, ...SAFETY_FILTERS.map((filter) => el( "button", { role: "tab", "data-testid": `filter-${filter}`, "aria-selected": filter === state ? "true" : "false", onclick: () => navigate("/safety", { state: filter }), }, filter ) ) ); const rows = data.items.map((item) => el( "tr", { "data-testid": "safety-row", "data-asset-id": item.asset_id }, el("td", {}, item.current_path || item.asset_id), el("td", { class: "muted" }, item.score == null ? "—" : item.score.toFixed(3)), el("td", { class: "muted" }, item.suggested || "—"), el( "td", {}, el("span", { class: `badge ${item.decision || ""}` }, item.decision || "undecided"), item.exif_verified ? el("span", { class: "badge sfw", title: "EXIF verified" }, "✓ exif") : null ), el( "td", { class: "decision-bar" }, decideButton(item, "sfw", "SFW"), decideButton(item, "nsfw", "NSFW"), decideButton(item, "deferred", "Defer") ) ) ); root.replaceChildren( el("h1", {}, "Safety review"), tabs, el( "table", { "aria-label": "Safety queue" }, el( "thead", {}, el("tr", {}, el("th", {}, "Path"), el("th", {}, "Score"), el("th", {}, "Suggested"), el("th", {}, "Decision"), el("th", {}, "Actions")) ), el("tbody", { "data-testid": "safety-body" }, ...rows) ), data.items.length ? null : el("p", { class: "muted" }, "Nothing in this queue.") ); } function decideButton(item, decision, label) { return el( "button", { "data-testid": `decide-${decision}`, class: item.decision === decision ? "primary" : "", onclick: async (event) => { event.target.disabled = true; try { await api.safetyDecide({ asset_id: item.asset_id, decision }); render(); } catch (error) { event.target.disabled = false; alert(`Decision failed: ${error.message}`); } }, }, label ); } // ── Library ────────────────────────────────────────────────────────────────── export async function renderLibrary(root, params) { setActiveNav("library"); const q = params.q || ""; const offset = Number(params.offset || 0); const limit = 60; let data; try { data = await api.libraryAssets({ q, offset, limit, sort: params.sort || "path" }); } catch (error) { root.replaceChildren(errorBanner(`Failed to load library: ${error.message}`)); return; } const search = el("input", { type: "search", placeholder: "Search descriptions and tags…", value: q, "aria-label": "Search library", onchange: (event) => navigate("/library", { q: event.target.value, offset: 0 }), }); const cards = data.rows.map((row) => el( "div", { class: "card lib-card", "data-testid": "library-card" }, el("img", { src: api.thumbnailUrl(row.asset_id, 256), alt: row.description || row.current_path, loading: "lazy" }), el("div", { class: "lib-desc" }, row.description || el("span", { class: "muted" }, "(not analyzed)")), el("div", { class: "muted" }, row.album || ""), el("div", { class: "tags" }, ...(row.tags || []).slice(0, 8).map((t) => el("span", { class: "badge" }, t))) ) ); root.replaceChildren( el("h1", {}, "Library"), el("div", { class: "toolbar" }, search, el("span", { class: "muted", "data-testid": "library-total" }, `${data.total} photos`)), data.rows.length ? el("div", { class: "cluster-grid" }, ...cards) : el("p", { class: "muted" }, "No matching photos."), pager("/library", params, offset, limit, data.total) ); } // ── Analyze ────────────────────────────────────────────────────────────────── export async function renderAnalyze(root) { setActiveNav("analyze"); let counts, workflow; try { [counts, workflow] = await Promise.all([api.analysisCounts(), api.workflow()]); } catch (error) { root.replaceChildren(errorBanner(`Failed to load analysis: ${error.message}`)); return; } const active = workflow.active_job; const log = el("pre", { class: "activity-log", "data-testid": "analyze-log" }); const runButton = el( "button", { class: "primary", "data-testid": "run-analysis", disabled: active || counts.eligible === 0 ? "disabled" : false, title: active ? "a job is already running" : counts.eligible === 0 ? "no confirmed-SFW assets" : false, onclick: async () => { runButton.disabled = true; try { const job = await api.analysisJob(); log.textContent = `Started analysis job ${job.id}\n`; subscribeJob(job.id, { onEvent: (event) => (log.textContent += `${event.type}${event.message ? ": " + event.message : ""}\n`), onDone: () => { log.textContent += "done\n"; renderAnalyze(root); }, }); } catch (error) { runButton.disabled = false; log.textContent = `Could not start: ${error.message}\n`; } }, }, "Analyze eligible SFW assets" ); root.replaceChildren( el("h1", {}, "Analyze"), jobBanner(active), el( "div", { class: "counts-grid", "data-testid": "analyze-counts" }, stat("Eligible (SFW)", counts.eligible), stat("Analyzed", counts.analyzed), stat("Pending", counts.pending), stat("Errors", counts.error) ), el("div", { class: "stage-actions" }, runButton), log ); } // ── Stats ──────────────────────────────────────────────────────────────────── export async function renderStats(root) { setActiveNav("stats"); let data; try { data = await api.libraryStats(); } catch (error) { root.replaceChildren(errorBanner(`Failed to load stats: ${error.message}`)); return; } root.replaceChildren( el("h1", {}, "Stats"), el("div", { class: "counts-grid", "data-testid": "stats-status" }, ...Object.entries(data.status).map(([k, v]) => stat(k, v))), facetBlock("Top tags", data.top_tags), facetBlock("Settings", data.setting), facetBlock("Years", data.years), el( "div", { "data-testid": "stats-albums" }, el("h2", {}, "Albums"), el( "table", { "aria-label": "Albums" }, el("thead", {}, el("tr", {}, el("th", {}, "Album"), el("th", {}, "Done"), el("th", {}, "Total"))), el( "tbody", {}, ...data.albums.map((album) => el("tr", {}, el("td", {}, album.album), el("td", {}, String(album.done)), el("td", {}, String(album.total))) ) ) ) ) ); } // ── Albums: evidence + naming proposals ────────────────────────────────────── // Two panes (concept §7): albums on the left with proposal state, the selected // album's evidence, rationale, confidence, and editable final name on the right. // Approving never renames anything — it records the approved name only. export async function renderAlbums(root, params = {}) { setActiveNav("albums"); let evidence, proposals; try { [evidence, proposals] = await Promise.all([ api.albumEvidence({ limit: 500 }), api.albumProposals(), ]); } catch (error) { root.replaceChildren(errorBanner(`Failed to load albums: ${error.message}`)); return; } const byAlbum = new Map(proposals.items.map((item) => [item.album, item])); const folders = evidence.folders; const selected = params.album || (folders[0] && folders[0].album) || null; const list = el( "ul", { class: "album-list", "data-testid": "album-list", "aria-label": "Albums" }, ...folders.map((folder) => { const proposal = byAlbum.get(folder.album); const state = proposal ? (proposal.stale ? "stale" : proposal.status) : "none"; return el( "li", {}, el( "a", { class: "link", href: `#/albums?album=${encodeURIComponent(folder.album)}`, "data-testid": "album-row", "data-album": folder.album, "aria-current": folder.album === selected ? "true" : false, }, folder.album, " ", el("span", { class: `badge ${state}`, "data-testid": "album-state" }, state), " ", el("span", { class: "muted" }, `${folder.asset_count} photos`) ) ); }) ); const detail = selected ? albumDetail( root, folders.find((folder) => folder.album === selected), byAlbum.get(selected), params ) : el("p", { class: "muted" }, "No albums with evidence yet."); root.replaceChildren( el("h1", {}, "Albums"), el( "div", { class: "toolbar" }, el( "button", { class: "primary", "data-testid": "generate-proposals", onclick: async () => { try { await api.generateProposals({}); render(); } catch (error) { alert(`Could not generate proposals: ${error.message}`); } }, }, "Generate proposals" ) ), el("div", { class: "two-pane" }, el("div", { class: "pane-left" }, list), el("div", { class: "pane-right" }, detail)) ); } function albumDetail(root, folder, proposal, params) { if (!folder) return el("p", { class: "muted" }, "Album not found."); const evidenceBlock = el( "div", { "data-testid": "album-evidence" }, el("h2", {}, "Evidence"), el( "dl", {}, el("dt", {}, "Source album"), el("dd", { "data-testid": "source-album" }, folder.album), el("dt", {}, "Photos affected"), el("dd", { "data-testid": "affected-count" }, String(folder.asset_count)), el("dt", {}, "Analyzed"), el("dd", {}, `${folder.analyzed_count} (${folder.nsfw_count} NSFW, ${folder.pending_count} pending)`), el("dt", {}, "Year"), el( "dd", {}, folder.dominant_year != null ? String(folder.dominant_year) : folder.year_conflict ? "conflicting" : "unknown" ), el("dt", {}, "Locations"), el("dd", {}, folder.locations.map((l) => l.value).join(", ") || "—") ), facetBlock("Top tags", folder.tags.slice(0, 12)) ); if (!proposal) { return el( "div", {}, el("h2", {}, folder.album), el("p", { class: "muted", "data-testid": "no-proposal" }, "No proposal yet — generate one."), evidenceBlock ); } const issues = el("div", { class: "alert", role: "alert", "data-testid": "name-issues", hidden: "hidden" }); const input = el("input", { type: "text", id: "final-name", value: proposal.name || "", "aria-label": "Final album name", "data-testid": "final-name", // Prompt validation: mirror the server's naming policy so a bad name is // flagged as it is typed, before any request is sent. oninput: () => showIssues(issues, input.value), }); const save = el( "button", { "data-testid": "save-name", onclick: () => mutate(root, () => api.editProposal(proposal.album, { name: input.value, expected_version: proposal.version }), params), }, "Save name" ); const approve = el( "button", { class: "primary", "data-testid": "approve", disabled: proposal.stale || proposal.status === "error" ? "disabled" : false, title: proposal.stale ? "evidence changed — regenerate first" : false, onclick: () => mutate(root, () => api.approveProposal(proposal.album, { expected_version: proposal.version }), params), }, proposal.status === "approved" ? "Approved" : "Approve name" ); const nodes = [ el("h2", {}, folder.album), el( "div", { class: "decision-bar" }, el("span", { class: `badge ${proposal.status}`, "data-testid": "proposal-status" }, proposal.status), proposal.confidence != null ? el("span", { class: "badge", "data-testid": "confidence" }, `confidence ${proposal.confidence}`) : null, proposal.stale ? el("span", { class: "badge attention", "data-testid": "stale" }, "stale — regenerate") : null ), ]; if (params.conflict) { nodes.push( el( "div", { class: "alert", role: "alert", "data-testid": "conflict" }, "This proposal changed since you loaded it. Nothing was modified; showing the latest state." ) ); } if (proposal.status === "error") { nodes.push( el("div", { class: "alert", role: "alert", "data-testid": "proposal-error" }, `Generation failed: ${proposal.error_code}`) ); } nodes.push( el("p", { class: "muted", "data-testid": "suggested-name" }, `Suggested: ${proposal.proposed_name || "—"}`), el("p", { "data-testid": "rationale" }, proposal.rationale || "No rationale given."), el("div", { class: "toolbar" }, el("label", { for: "final-name" }, "Final name "), input, save, approve), issues, evidenceBlock ); return el("div", {}, ...nodes); } // Mirrors photo_pipeline/services/naming.py so typing feedback matches the server. const FORBIDDEN_RE = /[/\\:*?"<>|]/; function showIssues(node, value) { const problems = []; if (FORBIDDEN_RE.test(value)) problems.push('a name cannot contain / \\ : * ? " < > |'); if (!value.replace(/[\s.]/g, "")) problems.push("a name cannot be empty"); node.textContent = problems.join("; "); node.hidden = problems.length === 0; } async function mutate(root, action, params) { try { await action(); render(); } catch (error) { if (error.status === 409) { // Stale browser state: show the conflict and the server's latest truth. navigate("/albums", { ...params, conflict: "1" }); render(); } else { alert(`Failed: ${error.message}`); } } } // ── shared bits ─────────────────────────────────────────────────────────────── function stat(label, value) { return el("div", { class: "stat card" }, el("div", { class: "stat-value" }, String(value ?? 0)), el("div", { class: "muted" }, label)); } function facetBlock(title, items) { if (!items || !items.length) return null; return el( "div", {}, el("h2", {}, title), el("div", { class: "tags" }, ...items.map((item) => el("span", { class: "badge" }, `${item.value} (${item.count})`))) ); } function pager(path, params, offset, limit, total) { const start = total === 0 ? 0 : offset + 1; const end = Math.min(offset + limit, total); return el( "div", { class: "pager" }, el( "button", { disabled: offset === 0 ? "disabled" : false, onclick: () => navigate(path, { ...params, offset: Math.max(offset - limit, 0) }) }, "Previous" ), el("span", { class: "muted" }, `${start}–${end} of ${total}`), el( "button", { disabled: end >= total ? "disabled" : false, onclick: () => navigate(path, { ...params, offset: offset + limit }) }, "Next" ) ); } // Re-render the current route (used after a mutation). Set by app.js. let render = () => {}; export function setRender(fn) { render = fn; }