US03-04: Review and Approve Proposals in the Browser (#64)
This commit was merged in pull request #64.
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user