(() => { "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 = '
' + 'Nessun cluster disponibile.' + '
'; 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 = '
' + 'Nessun volto non raggruppato.' + '
'; 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 = '
' + 'Nessuna persona è stata ' + 'ancora nominata.' + '
'; 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(); } })();