Files
photoanalyzer/frontend/js/views.js

585 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}