US01-06: Review Inventory and Duplicates in the Browser (#51)

This commit was merged in pull request #51.
This commit is contained in:
2026-07-15 21:44:48 +02:00
parent 98e9cb2988
commit 4b4c05339e
15 changed files with 1110 additions and 4 deletions

130
frontend/css/app.css Normal file
View File

@@ -0,0 +1,130 @@
:root {
--bg: #000;
--surface: #0e0e11;
--surface-2: #17171c;
--border: #26262e;
--text: #f4f4f6;
--muted: #9a9aa6;
--accent: #5b8cff;
--danger: #ff5b6e;
--ok: #3ddc97;
--warn: #ffcf5b;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-weight: 700; letter-spacing: 0.2px; }
nav a {
color: var(--muted);
text-decoration: none;
margin-right: 16px;
padding: 6px 0;
border-bottom: 2px solid transparent;
}
nav a[aria-current="page"] { color: var(--text); border-bottom-color: var(--accent); }
nav a:hover { color: var(--text); }
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
input, select, button { font: inherit; }
input[type="search"], select {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
}
button {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #06122e; font-weight: 600; }
button.danger { border-color: var(--danger); color: var(--danger); }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 13px; }
tbody tr { outline: none; }
tbody tr[aria-selected="true"] { background: rgba(91, 140, 255, 0.16); }
tbody tr:focus { box-shadow: inset 3px 0 0 var(--accent); }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
color: var(--muted);
}
.badge.exact, .badge.pixel { color: var(--ok); border-color: var(--ok); }
.badge.perceptual { color: var(--warn); border-color: var(--warn); }
.badge.decided { color: var(--ok); border-color: var(--ok); }
.badge.reopened, .badge.dismissed { color: var(--warn); border-color: var(--warn); }
.muted { color: var(--muted); }
.pager { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
.cluster-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.member {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.member.canonical { border-color: var(--accent); }
.member img { width: 100%; height: auto; background: var(--surface-2); border-radius: 8px; display: block; transform-origin: top left; }
.member dl { margin: 10px 0 0; font-size: 13px; }
.member dt { color: var(--muted); }
.member dd { margin: 0 0 6px; word-break: break-all; }
.decision-bar { display: flex; gap: 12px; margin: 20px 0; flex-wrap: wrap; align-items: center; }
.alert {
padding: 12px 16px;
border-radius: var(--radius);
border: 1px solid var(--danger);
color: var(--danger);
background: rgba(255, 91, 110, 0.1);
margin-bottom: 16px;
}
.confirm {
background: var(--surface);
border: 1px solid var(--warn);
border-radius: var(--radius);
padding: 16px;
margin: 16px 0;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; }
a.link { color: var(--accent); text-decoration: none; }
a.link:hover { text-decoration: underline; }

20
frontend/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Photo Pipeline — Review</title>
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<header class="app-header">
<span class="brand">Photo Pipeline</span>
<nav aria-label="Primary">
<a href="#/inventory" data-nav="inventory">Inventory</a>
<a href="#/duplicates" data-nav="duplicates">Duplicates</a>
</nav>
</header>
<main id="app" aria-live="polite"><!-- views render here --></main>
<script type="module" src="./js/app.js"></script>
</body>
</html>

38
frontend/js/api.js Normal file
View File

@@ -0,0 +1,38 @@
// Single API client: one place for fetch, JSON, and the error envelope.
const BASE = "/api/v1";
async function request(path, options = {}) {
const response = await fetch(BASE + path, {
headers: { "Content-Type": "application/json" },
...options,
});
let body = null;
try {
body = await response.json();
} catch (_) {
body = null;
}
if (!response.ok) {
const envelope = body && body.error ? body.error : {};
const error = new Error(envelope.message || response.statusText);
error.status = response.status;
error.code = envelope.code || "error";
throw error;
}
return body;
}
export const api = {
listAssets: (params = {}) =>
request("/inventory/assets?" + new URLSearchParams(params).toString()),
listClusters: (params = {}) =>
request("/duplicates/clusters?" + new URLSearchParams(params).toString()),
getCluster: (id) => request(`/duplicates/clusters/${encodeURIComponent(id)}`),
decide: (id, payload) =>
request(`/duplicates/clusters/${encodeURIComponent(id)}/decision`, {
method: "POST",
body: JSON.stringify(payload),
}),
thumbnailUrl: (assetId, size = 512) =>
`${BASE}/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`,
};

355
frontend/js/app.js Normal file
View File

@@ -0,0 +1,355 @@
import { api } from "./api.js";
import { navigate, onRouteChange, parseHash } from "./router.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 === "/inventory" || path === "/") renderInventory(params);
else if (path === "/duplicates") renderClusters(params);
else if (clusterMatch) renderClusterDetail(clusterMatch[1]);
else show(errorBanner("Unknown view"));
}
onRouteChange(render);
render();

20
frontend/js/router.js Normal file
View File

@@ -0,0 +1,20 @@
// Hash router. URLs are stable and shareable: the view, its filters, and paging
// all live in the hash, so a reload restores exactly what was on screen.
export function parseHash() {
const raw = location.hash.slice(1) || "/inventory";
const [path, queryString = ""] = raw.split("?");
return { path, params: Object.fromEntries(new URLSearchParams(queryString)) };
}
export function navigate(path, params = {}) {
const clean = Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== "")
);
const query = Object.keys(clean).length ? "?" + new URLSearchParams(clean) : "";
location.hash = path + query;
}
export function onRouteChange(handler) {
window.addEventListener("hashchange", handler);
window.addEventListener("DOMContentLoaded", handler);
}