galleria_photo_face_rknn_op.../public/js/faces.js
2026-10-03 12:16:58 +02:00

1866 lines
37 KiB
JavaScript

(() => {
"use strict";
const state = {
token: "",
context: null,
targetUser: "",
clusters: [],
unclustered: [],
persons: [],
objectUrls: new Set(),
};
function getToken() {
return localStorage.getItem(
"token",
) || "";
}
function getRequestedUser() {
const parameters =
new URLSearchParams(
window.location.search,
);
return String(
parameters.get("user") || "",
).trim();
}
function buildApiUrl(path) {
const url = new URL(
path,
window.location.origin,
);
const requestedUser =
getRequestedUser();
if (requestedUser) {
url.searchParams.set(
"user",
requestedUser,
);
}
return (
url.pathname
+ url.search
);
}
async function apiRequest(
path,
options = {},
) {
const headers = {
...(options.headers || {}),
Authorization:
`Bearer ${state.token}`,
};
const response = await fetch(
buildApiUrl(path),
{
...options,
headers,
},
);
if (response.status === 401) {
localStorage.removeItem("token");
window.location.replace(
"/login?redirect="
+ encodeURIComponent(
window.location.pathname
+ window.location.search,
),
);
throw new Error(
"Sessione scaduta",
);
}
if (!response.ok) {
let message =
`Errore HTTP ${response.status}`;
try {
const payload =
await response.json();
message =
payload.error
|| payload.message
|| message;
} catch {
// Mantiene il messaggio HTTP.
}
throw new Error(message);
}
return response.json();
}
async function apiWrite(
path,
body,
) {
return apiRequest(
path,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify(body),
},
);
}
async function loadProtectedImage(
image,
thumbnailUrl,
) {
try {
image.classList.add("loading");
const response = await fetch(
buildApiUrl(thumbnailUrl),
{
headers: {
Authorization:
`Bearer ${state.token}`,
},
},
);
if (!response.ok) {
throw new Error(
`Miniatura HTTP ${response.status}`,
);
}
const blob = await response.blob();
const objectUrl =
URL.createObjectURL(blob);
state.objectUrls.add(
objectUrl,
);
image.src = objectUrl;
image.classList.remove("loading");
} catch (error) {
console.error(
"[faces] errore miniatura:",
error,
);
const wrapper =
image.closest(
".face-image-wrapper",
);
if (wrapper) {
wrapper.innerHTML = "";
const placeholder =
document.createElement("div");
placeholder.className =
"face-placeholder";
placeholder.textContent =
"Miniatura non disponibile";
wrapper.appendChild(
placeholder,
);
}
}
}
function releaseObjectUrls() {
for (
const objectUrl
of state.objectUrls
) {
URL.revokeObjectURL(
objectUrl,
);
}
state.objectUrls.clear();
}
function setStatus(
message,
type = "",
) {
const panel =
document.getElementById(
"statusPanel",
);
panel.textContent = message;
panel.classList.remove(
"error",
"success",
);
if (type) {
panel.classList.add(type);
}
}
function createFaceCard(
face,
options = {},
) {
const article =
document.createElement(
"article",
);
article.className = "face-card";
if (options.selectable) {
article.classList.add(
"selectable",
);
const selection =
document.createElement("label");
selection.className =
"face-selection";
const checkbox =
document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked =
options.checked !== false;
checkbox.dataset.faceId =
face.id;
const selectionText =
document.createElement("span");
selectionText.textContent =
"Includi";
selection.append(
checkbox,
selectionText,
);
article.appendChild(selection);
}
const imageWrapper =
document.createElement("div");
imageWrapper.className =
"face-image-wrapper";
const image =
document.createElement("img");
image.className =
"face-image loading";
image.alt =
`Volto in ${face.photo_name}`;
image.loading = "lazy";
imageWrapper.appendChild(image);
const body =
document.createElement("div");
body.className = "face-body";
const name =
document.createElement("div");
name.className = "face-name";
name.textContent =
face.photo_name
|| "Fotografia";
const details =
document.createElement("p");
details.className =
"face-details";
const probability =
face.probability == null
? "n/d"
: Number(
face.probability,
).toFixed(3);
const detectorScore =
Number(
face.detector_score || 0,
).toFixed(3);
details.textContent =
`Volto ${face.face_index}`
+ ` · detector ${detectorScore}`
+ ` · probabilità ${probability}`;
body.append(
name,
details,
);
article.append(
imageWrapper,
body,
);
loadProtectedImage(
image,
face.thumbnail_url,
);
return article;
}
function getSelectedFaceIds(
clusterElement,
) {
return Array.from(
clusterElement.querySelectorAll(
'input[data-face-id\]:checked',
),
).map(
(checkbox) =>
checkbox.dataset.faceId,
);
}
async function createPerson(
name,
) {
const response = await apiWrite(
"/face-review/persons",
{ name },
);
return response.person;
}
async function assignClusterPerson(
cluster,
clusterElement,
controls,
) {
let personId = String(
controls.personSelect.value || "",
).trim();
if (!personId) {
const name = String(
controls.nameInput.value || "",
)
.replace(/\s+/g, " ")
.trim();
if (!name) {
throw new Error(
"Inserisci un nome oppure "
+ "seleziona una persona"
);
}
const person =
await createPerson(name);
personId = person.id;
}
const includedFaceIds =
getSelectedFaceIds(
clusterElement,
);
if (!includedFaceIds.length) {
throw new Error(
"Seleziona almeno un volto"
);
}
controls.assignButton.disabled = true;
controls.assignButton.textContent =
"Salvataggio...";
try {
await apiWrite(
`/face-review/clusters/${
encodeURIComponent(cluster.id)
}/assign-person`,
{
person_id: personId,
included_face_ids:
includedFaceIds,
},
);
await loadData();
setStatus(
"Persona assegnata correttamente",
"success",
);
} finally {
controls.assignButton.disabled =
false;
controls.assignButton.textContent =
"Assegna persona";
}
}
function createClusterControls(
cluster,
clusterElement,
) {
const wrapper =
document.createElement("div");
wrapper.className =
"cluster-controls";
const nameInput =
document.createElement("input");
nameInput.type = "text";
nameInput.maxLength = 120;
nameInput.placeholder =
"Nome nuova persona";
nameInput.className =
"person-name-input";
const personSelect =
document.createElement("select");
personSelect.className =
"person-select";
const emptyOption =
document.createElement("option");
emptyOption.value = "";
emptyOption.textContent =
"Crea nuova persona";
personSelect.appendChild(emptyOption);
for (const person of state.persons) {
const option =
document.createElement("option");
option.value = person.id;
option.textContent = person.name;
if (
cluster.person_id === person.id
) {
option.selected = true;
}
personSelect.appendChild(option);
}
const assignButton =
document.createElement("button");
assignButton.type = "button";
assignButton.className =
"primary-button";
assignButton.textContent =
"Assegna persona";
const controls = {
wrapper,
nameInput,
personSelect,
assignButton,
};
assignButton.addEventListener(
"click",
async () => {
try {
await assignClusterPerson(
cluster,
clusterElement,
controls,
);
} catch (error) {
console.error(
"[faces] assegnazione:",
error,
);
setStatus(
error.message,
"error",
);
}
},
);
wrapper.append(
nameInput,
personSelect,
assignButton,
);
return wrapper;
}
function renderClusters() {
const grid =
document.getElementById(
"clustersGrid",
);
grid.innerHTML = "";
if (!state.clusters.length) {
grid.innerHTML =
'<div class="empty-state">'
+ 'Nessun cluster disponibile.'
+ '</div>';
return;
}
for (
const cluster
of state.clusters
) {
const article =
document.createElement(
"article",
);
article.className =
"cluster-card";
const header =
document.createElement("div");
header.className =
"cluster-header";
const titleWrapper =
document.createElement("div");
const title =
document.createElement("h3");
title.className =
"cluster-title";
title.textContent =
cluster.person_name
|| `Cluster ${cluster.label}`;
const metadata =
document.createElement("p");
metadata.className =
"cluster-metadata";
metadata.textContent =
cluster.person_name
? `Cluster ${cluster.label}`
: "Persona non identificata";
titleWrapper.append(
title,
metadata,
);
const badge =
document.createElement("span");
badge.className =
"cluster-size-badge";
badge.textContent =
`${cluster.size} volti`;
header.append(
titleWrapper,
badge,
);
const faceGrid =
document.createElement("div");
faceGrid.className =
"face-grid";
for (
const face
of cluster.faces || []
) {
faceGrid.appendChild(
createFaceCard(
face,
{
selectable: true,
checked:
face.review_status
!== "excluded",
},
),
);
}
const controls =
createClusterControls(
cluster,
article,
);
article.append(
header,
faceGrid,
controls,
);
grid.appendChild(article);
}
}
async function assignFacePerson(
face,
personId,
) {
if (!personId) {
throw new Error(
"Seleziona una persona",
);
}
return apiWrite(
`/face-review/faces/${
encodeURIComponent(face.id)
}/assign-person`,
{
person_id: personId,
},
);
}
function createUnclusteredFaceCard(
face,
) {
const card =
createFaceCard(face);
const body =
card.querySelector(
".face-body",
);
const controls =
document.createElement("div");
controls.className =
"noise-person-controls";
const select =
document.createElement("select");
select.className =
"person-select";
const emptyOption =
document.createElement("option");
emptyOption.value = "";
emptyOption.textContent =
"Seleziona persona";
select.appendChild(emptyOption);
for (const person of state.persons) {
const option =
document.createElement(
"option",
);
option.value = person.id;
option.textContent =
person.name;
select.appendChild(option);
}
const button =
document.createElement("button");
button.type = "button";
button.className =
"primary-button";
button.textContent =
"Assegna";
button.addEventListener(
"click",
async () => {
button.disabled = true;
button.textContent =
"Salvataggio...";
try {
await assignFacePerson(
face,
select.value,
);
await loadData();
setStatus(
"Volto assegnato "
+ "alla persona",
"success",
);
} catch (error) {
console.error(
"[faces] assegnazione "
+ "volto noise:",
error,
);
setStatus(
error.message,
"error",
);
button.disabled = false;
button.textContent =
"Assegna";
}
},
);
controls.append(
select,
button,
);
if (body) {
body.appendChild(controls);
}
return card;
}
function getSelectedNoiseFaceIds() {
return Array.from(
document.querySelectorAll(
'#unclusteredGrid '
+ 'input[data-noise-face-id\]:checked',
),
).map(
(checkbox) =>
checkbox.dataset.noiseFaceId,
);
}
function updateBulkNoiseCount(
controls,
) {
const selected =
getSelectedNoiseFaceIds();
controls.assignButton.textContent =
selected.length
? `Assegna ${selected.length} volti`
: "Assegna volti selezionati";
controls.assignButton.disabled =
selected.length === 0;
}
function setAllNoiseSelections(
checked,
controls,
) {
document
.querySelectorAll(
'#unclusteredGrid '
+ 'input[data-noise-face-id]',
)
.forEach((checkbox) => {
checkbox.checked = checked;
});
updateBulkNoiseCount(controls);
}
async function assignSelectedNoiseFaces(
controls,
) {
const faceIds =
getSelectedNoiseFaceIds();
if (!faceIds.length) {
throw new Error(
"Seleziona almeno un volto",
);
}
const personId = String(
controls.personSelect.value || "",
).trim();
const name = String(
controls.nameInput.value || "",
)
.replace(/\s+/g, " ")
.trim();
if (!personId && !name) {
throw new Error(
"Seleziona una persona oppure "
+ "inserisci un nuovo nome",
);
}
controls.assignButton.disabled = true;
controls.assignButton.textContent =
"Salvataggio...";
try {
const payload = {
face_ids: faceIds,
};
if (personId) {
payload.person_id = personId;
} else {
payload.name = name;
}
const response = await apiWrite(
"/face-review/unclustered/"
+ "assign-person",
payload,
);
await loadData();
setStatus(
`${response.assigned_count} volti `
+ `assegnati a ${
response.person.name
}`,
"success",
);
} finally {
controls.assignButton.disabled =
false;
updateBulkNoiseCount(controls);
}
}
function createBulkNoiseControls() {
const wrapper =
document.createElement("div");
wrapper.className =
"bulk-noise-controls";
const selectionActions =
document.createElement("div");
selectionActions.className =
"bulk-selection-actions";
const selectAllButton =
document.createElement("button");
selectAllButton.type = "button";
selectAllButton.className =
"secondary-button";
selectAllButton.textContent =
"Seleziona tutti";
const clearButton =
document.createElement("button");
clearButton.type = "button";
clearButton.className =
"secondary-button";
clearButton.textContent =
"Deseleziona tutti";
selectionActions.append(
selectAllButton,
clearButton,
);
const nameInput =
document.createElement("input");
nameInput.type = "text";
nameInput.maxLength = 120;
nameInput.placeholder =
"Nome nuova persona";
nameInput.className =
"person-name-input";
const personSelect =
document.createElement("select");
personSelect.className =
"person-select";
const emptyOption =
document.createElement("option");
emptyOption.value = "";
emptyOption.textContent =
"Crea nuova persona";
personSelect.appendChild(
emptyOption,
);
for (const person of state.persons) {
const option =
document.createElement("option");
option.value = person.id;
option.textContent =
person.name;
personSelect.appendChild(option);
}
const assignButton =
document.createElement("button");
assignButton.type = "button";
assignButton.className =
"primary-button";
assignButton.textContent =
"Assegna volti selezionati";
assignButton.disabled = true;
const controls = {
wrapper,
nameInput,
personSelect,
assignButton,
};
selectAllButton.addEventListener(
"click",
() => {
setAllNoiseSelections(
true,
controls,
);
},
);
clearButton.addEventListener(
"click",
() => {
setAllNoiseSelections(
false,
controls,
);
},
);
assignButton.addEventListener(
"click",
async () => {
try {
await assignSelectedNoiseFaces(
controls,
);
} catch (error) {
console.error(
"[faces] assegnazione bulk:",
error,
);
setStatus(
error.message,
"error",
);
}
},
);
wrapper.append(
selectionActions,
nameInput,
personSelect,
assignButton,
);
return controls;
}
function renderUnclustered() {
const grid =
document.getElementById(
"unclusteredGrid",
);
grid.innerHTML = "";
const section =
document.getElementById(
"unclusteredSection",
);
section
.querySelector(
".bulk-noise-controls",
)
?.remove();
if (!state.unclustered.length) {
grid.innerHTML =
'<div class="empty-state">'
+ 'Nessun volto non raggruppato.'
+ '</div>';
return;
}
const controls =
createBulkNoiseControls();
const heading =
section.querySelector(
".section-heading",
);
heading.insertAdjacentElement(
"afterend",
controls.wrapper,
);
for (
const face
of state.unclustered
) {
const card =
createFaceCard(face);
card.classList.add(
"noise-selectable",
);
const label =
document.createElement("label");
label.className =
"noise-selection";
const checkbox =
document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.noiseFaceId =
face.id;
const labelText =
document.createElement("span");
labelText.textContent =
"Seleziona";
checkbox.addEventListener(
"change",
() => {
updateBulkNoiseCount(
controls,
);
},
);
label.append(
checkbox,
labelText,
);
card.prepend(label);
grid.appendChild(card);
}
updateBulkNoiseCount(controls);
}
async function apiMutation(
path,
method,
body = null,
) {
const options = {
method,
headers: {},
};
if (body !== null) {
options.headers[
"Content-Type"
] = "application/json";
options.body =
JSON.stringify(body);
}
return apiRequest(
path,
options,
);
}
function closePersonDetail() {
const overlay =
document.getElementById(
"personDetailOverlay",
);
if (overlay) {
overlay.remove();
}
}
async function removeFaceFromPerson(
person,
face,
) {
const confirmed = window.confirm(
`Rimuovere il volto di ${
face.photo_name
} da ${person.name}?`,
);
if (!confirmed) {
return;
}
await apiMutation(
`/face-review/persons/${
encodeURIComponent(person.id)
}/faces/${
encodeURIComponent(face.id)
}`,
"DELETE",
);
}
async function renamePerson(
person,
input,
) {
const name = String(
input.value || "",
)
.replace(/\s+/g, " ")
.trim();
if (!name) {
throw new Error(
"Inserisci un nome valido",
);
}
await apiMutation(
`/face-review/persons/${
encodeURIComponent(person.id)
}`,
"PATCH",
{ name },
);
}
function createPersonFaceCard(person, face, overlay) {
const card = createFaceCard(face);
const body = card.querySelector(".face-body");
const wrapper = card.querySelector(".face-image-wrapper");
const badges = document.createElement("div");
badges.className = "person-face-badges";
const addBadge = (text, className) => {
const badge = document.createElement("span");
badge.className = `person-face-badge ${className}`;
badge.textContent = text;
badges.appendChild(badge);
};
if (face.is_prototype) {
const order = face.prototype?.selection_order;
addBadge(order == null ? "Prototype" : `Prototype #${order}`, "prototype-badge");
}
if (face.is_reference_excluded) {
addBadge("Caso difficile", "difficult-badge");
}
if (
face.recognition?.evaluated
) {
const recognitionScore =
Number(
face.recognition
.best_similarity,
);
const scoreText =
Number.isFinite(
recognitionScore,
)
? recognitionScore.toFixed(2)
: "n/d";
if (
face.recognition.recognized
) {
addBadge(
`Riconosciuto ${scoreText}`,
"recognized-badge",
);
} else {
addBadge(
`Sotto soglia ${scoreText}`,
"below-threshold-badge",
);
}
}
if (face.cluster_label == null) {
addBadge("Noise confermato", "noise-badge");
} else {
addBadge(`Cluster ${face.cluster_label}`, "cluster-badge");
}
if (wrapper) wrapper.appendChild(badges);
const info = document.createElement("p");
info.className = "face-details";
info.textContent = face.cluster_label == null
? "Senza cluster automatico"
: `Cluster ${face.cluster_label}`;
const referenceInfo = document.createElement("div");
referenceInfo.className = "reference-info";
if (face.is_prototype) {
referenceInfo.classList.add("prototype-info");
referenceInfo.textContent = face.prototype?.selection_method === "cluster_medoid"
? "Riferimento centrale del cluster"
: "Riferimento scelto per copertura";
} else if (face.is_reference_excluded) {
referenceInfo.classList.add("difficult-info");
referenceInfo.textContent = face.reference_exclusion?.note
|| face.reference_exclusion?.reason_code
|| "Non usare come riferimento";
} else {
referenceInfo.hidden = true;
}
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "danger-button";
removeButton.textContent = "Rimuovi dalla persona";
removeButton.addEventListener("click", async () => {
removeButton.disabled = true;
try {
await removeFaceFromPerson(person, face);
overlay.remove();
await loadData();
setStatus("Volto rimosso dalla persona", "success");
} catch (error) {
console.error("[faces] rimozione volto:", error);
setStatus(error.message, "error");
removeButton.disabled = false;
}
});
if (body) body.append(info, referenceInfo, removeButton);
return card;
}
function personFaceMatches(face, filter) {
if (filter === "prototype") return Boolean(face.is_prototype);
if (filter === "clustered") return face.cluster_id != null;
if (filter === "noise") return face.cluster_id == null;
if (filter === "difficult") return Boolean(face.is_reference_excluded);
if (
filter === "recognized"
) {
return Boolean(
face.recognition?.evaluated
&& face.recognition?.recognized,
);
}
if (
filter === "below"
) {
return Boolean(
face.recognition?.evaluated
&& !face.recognition?.recognized,
);
}
return true;
}
async function openPersonDetail(personId) {
closePersonDetail();
setStatus("Caricamento della persona...");
const data = await apiRequest(`/face-review/persons/${encodeURIComponent(personId)}`);
const person = data.person;
const summary = data.summary || {};
const faces = data.faces || [];
const indexState = data.index_state || {};
const overlay = document.createElement("div");
overlay.id = "personDetailOverlay";
overlay.className = "person-detail-overlay";
const dialog = document.createElement("section");
dialog.className = "person-detail-dialog";
const header = document.createElement("header");
header.className = "person-detail-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.textContent = person.name;
const summaryText = document.createElement("p");
summaryText.className = "page-subtitle";
summaryText.textContent = `${summary.face_count || 0} volti | ${summary.cluster_count || 0} cluster | ${summary.prototype_count || 0} prototype | ${summary.difficult_face_count || 0} casi difficili`;
const coverageText =
document.createElement("p");
coverageText.className =
"recognition-coverage-summary";
if (
summary.evaluated_face_count > 0
) {
const coveragePercent =
Number(
summary.recognition_coverage_percent
|| 0,
).toFixed(1);
const recognitionThreshold =
Number(
summary.recognition_threshold
|| 0.5,
).toFixed(2);
coverageText.textContent =
`${summary.recognized_face_count || 0}`
+ ` / ${summary.evaluated_face_count}`
+ " riconosciuti"
+ ` | ${coveragePercent}%`
+ ` | soglia ${recognitionThreshold}`;
} else {
coverageText.textContent =
"Copertura non ancora calcolata";
}
heading.append(
title,
summaryText,
coverageText,
);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "secondary-button";
closeButton.textContent = "Chiudi";
closeButton.addEventListener("click", closePersonDetail);
header.append(heading, closeButton);
const indexPanel = document.createElement("section");
indexPanel.className = "recognition-index-panel";
const indexTitle = document.createElement("strong");
indexTitle.textContent = "Indice riconoscimento";
const indexStatus = document.createElement("span");
indexStatus.className = indexState.dirty ? "index-status dirty" : "index-status ready";
indexStatus.textContent = indexState.dirty ? "Da aggiornare" : "Aggiornato";
const indexDetails = document.createElement("span");
indexDetails.className = "index-state-details";
indexDetails.textContent = `Versione ${indexState.version || 0} | ${indexState.prototype_count || 0} prototype`;
indexPanel.append(indexTitle, indexStatus, indexDetails);
const renameArea = document.createElement("div");
renameArea.className = "person-rename-area";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.maxLength = 120;
nameInput.value = person.name;
nameInput.className = "person-name-input";
const renameButton = document.createElement("button");
renameButton.type = "button";
renameButton.className = "primary-button";
renameButton.textContent = "Rinomina";
renameButton.addEventListener("click", async () => {
renameButton.disabled = true;
try {
await renamePerson(person, nameInput);
overlay.remove();
await loadData();
setStatus("Persona rinominata", "success");
} catch (error) {
console.error("[faces] rinomina:", error);
setStatus(error.message, "error");
renameButton.disabled = false;
}
});
renameArea.append(nameInput, renameButton);
const faceGrid = document.createElement("div");
faceGrid.className = "face-grid person-detail-faces";
faces.forEach((face) => faceGrid.appendChild(createPersonFaceCard(person, face, overlay)));
const filters = document.createElement("nav");
filters.className = "person-face-filters";
const definitions = [
["all", "Tutti", summary.face_count || faces.length],
["prototype", "Prototype", summary.prototype_count || 0],
["clustered", "Clusterizzati", summary.clustered_face_count || 0],
["noise", "Noise confermati", summary.confirmed_noise_count || 0],
["recognized", "Riconosciuti", summary.recognized_face_count || 0],
["below", "Sotto soglia", summary.below_threshold_count || 0],
["difficult", "Casi difficili", summary.difficult_face_count || 0],
];
const buttons = [];
definitions.forEach(([filter, label, count]) => {
const button = document.createElement("button");
button.type = "button";
button.className = "person-face-filter";
button.dataset.filter = filter;
if (filter === "all") button.classList.add("active");
button.textContent = `${label} ${count}`;
button.addEventListener("click", () => {
buttons.forEach((item) => item.classList.toggle("active", item === button));
Array.from(faceGrid.children).forEach((card, index) => {
card.classList.toggle("hidden", !personFaceMatches(faces[index], filter));
});
});
buttons.push(button);
filters.appendChild(button);
});
dialog.append(header, indexPanel, renameArea, filters, faceGrid);
overlay.appendChild(dialog);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) closePersonDetail();
});
document.body.appendChild(overlay);
setStatus(`Dettaglio di ${person.name}`, "success");
}
function renderPersons() {
const grid =
document.getElementById(
"personsGrid",
);
grid.innerHTML = "";
if (!state.persons.length) {
grid.innerHTML =
'<div class="empty-state">'
+ 'Nessuna persona è stata '
+ 'ancora nominata.'
+ '</div>';
return;
}
for (
const person
of state.persons
) {
const card =
document.createElement(
"article",
);
card.className =
"person-card clickable";
card.tabIndex = 0;
card.setAttribute(
"role",
"button",
);
const title =
document.createElement("h3");
title.textContent =
person.name;
const details =
document.createElement("p");
details.className =
"face-details";
details.textContent =
"Apri per vedere e modificare "
+ "i volti assegnati";
const openButton =
document.createElement(
"button",
);
openButton.type = "button";
openButton.className =
"secondary-button";
openButton.textContent =
"Apri persona";
const open = async () => {
try {
await openPersonDetail(
person.id,
);
} catch (error) {
console.error(
"[faces] dettaglio persona:",
error,
);
setStatus(
error.message,
"error",
);
}
};
openButton.addEventListener(
"click",
(event) => {
event.stopPropagation();
open();
},
);
card.addEventListener(
"click",
open,
);
card.addEventListener(
"keydown",
(event) => {
if (
event.key === "Enter"
|| event.key === " "
) {
event.preventDefault();
open();
}
},
);
card.append(
title,
details,
openButton,
);
grid.appendChild(card);
}
}
function updateCounters() {
document.getElementById(
"clusterCount",
).textContent =
String(state.clusters.length);
document.getElementById(
"unclusteredCount",
).textContent =
String(
state.unclustered.length,
);
document.getElementById(
"personCount",
).textContent =
String(state.persons.length);
}
function initializeTabs() {
const tabs =
document.querySelectorAll(
".review-tab",
);
const sections =
document.querySelectorAll(
"[data-section]",
);
for (const tab of tabs) {
tab.addEventListener(
"click",
() => {
const selected =
tab.dataset.tab;
for (
const currentTab
of tabs
) {
currentTab.classList.toggle(
"active",
currentTab === tab,
);
}
for (
const section
of sections
) {
section.classList.toggle(
"hidden",
section.dataset.section
!== selected,
);
}
},
);
}
}
function initializeButtons() {
document
.getElementById("refreshButton")
.addEventListener(
"click",
loadData,
);
document
.getElementById("backButton")
.addEventListener(
"click",
() => {
if (
state.context?.is_admin
) {
window.location.href =
"/admin.html";
} else {
window.location.href = "/";
}
},
);
}
async function loadData() {
try {
releaseObjectUrls();
setStatus(
"Caricamento dei dati...",
);
const context =
await apiRequest(
"/face-review/context",
);
state.context = context;
state.targetUser =
context.target_user;
const [
clusterData,
unclusteredData,
personData,
] = await Promise.all([
apiRequest(
"/face-review/clusters",
),
apiRequest(
"/face-review/unclustered",
),
apiRequest(
"/face-review/persons",
),
]);
state.clusters =
clusterData.clusters || [];
state.unclustered =
unclusteredData.faces || [];
state.persons =
personData.persons || [];
document.getElementById(
"pageSubtitle",
).textContent =
`Libreria di ${state.targetUser}`;
renderClusters();
renderUnclustered();
renderPersons();
updateCounters();
setStatus(
`${state.clusters.length} cluster, `
+ `${state.unclustered.length} `
+ `volti non raggruppati`,
"success",
);
} catch (error) {
console.error(
"[faces] caricamento fallito:",
error,
);
setStatus(
error.message,
"error",
);
}
}
function initialize() {
state.token = getToken();
if (
!window.AppAuth
|| !window.AppAuth.requireAuth()
) {
return;
}
const loader =
document.getElementById(
"authLoader",
);
const app =
document.getElementById("app");
if (loader) {
loader.hidden = true;
loader.style.display = "none";
}
if (!app) {
throw new Error(
"Contenitore applicazione non trovato",
);
}
app.hidden = false;
app.removeAttribute("aria-hidden");
app.style.removeProperty("display");
initializeTabs();
initializeButtons();
loadData();
}
window.addEventListener(
"beforeunload",
releaseObjectUrls,
);
if (
document.readyState
=== "loading"
) {
document.addEventListener(
"DOMContentLoaded",
initialize,
{
once: true,
},
);
} else {
initialize();
}
})();