Files
photoanalyzer/frontend/js/app.js

382 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();