382 lines
12 KiB
JavaScript
382 lines
12 KiB
JavaScript
import { api } from "./api.js";
|
||
import { renderArchive, setArchiveRender } from "./archive.js";
|
||
import { navigate, onRouteChange, parseHash } from "./router.js";
|
||
import { renderRenames, setRenamesRender } from "./renames.js";
|
||
import { renderUploads, setUploadsRender } from "./uploads.js";
|
||
import {
|
||
renderAlbums,
|
||
renderAnalyze,
|
||
renderLibrary,
|
||
renderSafety,
|
||
renderStats,
|
||
renderWorkflow,
|
||
setRender,
|
||
} from "./views.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 === "/workflow" || path === "/") renderWorkflow(root, params);
|
||
else if (path === "/inventory") renderInventory(params);
|
||
else if (path === "/duplicates") renderClusters(params);
|
||
else if (clusterMatch) renderClusterDetail(clusterMatch[1]);
|
||
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 === "/renames") renderRenames(root, params);
|
||
else if (path === "/uploads") renderUploads(root, params);
|
||
else if (path === "/archive") renderArchive(root, params);
|
||
else if (path === "/stats") renderStats(root, params);
|
||
else show(errorBanner("Unknown view"));
|
||
}
|
||
|
||
// Let views re-render the current route after a mutation.
|
||
setRender(render);
|
||
setRenamesRender(render);
|
||
setUploadsRender(render);
|
||
setArchiveRender(render);
|
||
onRouteChange(render);
|
||
render();
|