US03-04: Review and Approve Proposals in the Browser (#64)

This commit was merged in pull request #64.
This commit is contained in:
2026-08-15 14:07:47 +02:00
parent 9681ced9f4
commit 703ed3167d
7 changed files with 484 additions and 0 deletions

View File

@@ -79,4 +79,24 @@ export const api = {
libraryAssets: (params = {}, opts = {}) =>
request("/library/assets?" + new URLSearchParams(params).toString(), opts),
libraryStats: (opts = {}) => request("/library/stats", opts),
// ── Albums: evidence and naming proposals ───────────────────────────────
albumEvidence: (params = {}, opts = {}) =>
request("/albums/evidence?" + new URLSearchParams(params).toString(), opts),
albumProposals: (params = {}, opts = {}) =>
request("/albums/proposals?" + new URLSearchParams(params).toString(), opts),
generateProposals: (payload = {}, opts = {}) =>
request("/albums/proposals", { method: "POST", body: JSON.stringify(payload), ...opts }),
editProposal: (album, payload, opts = {}) =>
request(`/albums/proposals/${encodeURIComponent(album)}/edit`, {
method: "POST",
body: JSON.stringify(payload),
...opts,
}),
approveProposal: (album, payload, opts = {}) =>
request(`/albums/proposals/${encodeURIComponent(album)}/approve`, {
method: "POST",
body: JSON.stringify(payload),
...opts,
}),
};

View File

@@ -1,6 +1,7 @@
import { api } from "./api.js";
import { navigate, onRouteChange, parseHash } from "./router.js";
import {
renderAlbums,
renderAnalyze,
renderLibrary,
renderSafety,
@@ -360,6 +361,7 @@ function render() {
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 === "/stats") renderStats(root, params);
else show(errorBanner("Unknown view"));
}

View File

@@ -321,6 +321,227 @@ export async function renderStats(root) {
);
}
// ── 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));