US01-06: Review Inventory and Duplicates in the Browser (#51)
This commit was merged in pull request #51.
This commit is contained in:
130
frontend/css/app.css
Normal file
130
frontend/css/app.css
Normal 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
20
frontend/index.html
Normal 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
38
frontend/js/api.js
Normal 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
355
frontend/js/app.js
Normal 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
20
frontend/js/router.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user