Regalamiunsorriso/faceai/apps/frontend/src/composables/useFaceAiHome.js

583 lines
19 KiB
JavaScript
Raw Normal View History

import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { legacyUrl } from '../legacyUrls.js';
const copy = {
it: {
pageTitle: 'Face ID',
pageHeadline: 'Trova le tue foto con un selfie',
pageIntro: 'Carica una tua immagine recente e lascia che Face ID cerchi le corrispondenze solo nella gara corrente.',
userFallback: 'Sessione FaceAI',
raceFallback: 'Gara corrente',
statusReady: 'Pronto',
statusProcessing: 'In lavorazione',
statusCompleted: 'Completata',
statusFailed: 'Errore',
statusLabel: 'Stato',
userLabel: 'Utente',
raceLabel: 'Gara',
languageLabel: 'Lingua',
uploaderTitle: 'Carica il tuo selfie',
uploaderHint: 'Puoi trascinare un file immagine oppure selezionarlo dal dispositivo.',
uploaderDragIdle: 'Trascina qui il selfie',
uploaderDragActive: 'Rilascia limmagine per caricarla',
uploaderBrowse: 'Scegli immagine',
uploaderFormats: 'Formati supportati: JPG, PNG, WEBP',
uploaderSelected: 'File selezionato',
uploaderReplace: 'Sostituisci',
uploaderRemove: 'Rimuovi',
backButton: 'Torna alla pagina gara',
uploadButton: 'Avvia ricerca Face ID',
openSimulator: 'Apri il simulatore legacy',
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
sessionLoading: 'Caricamento della sessione FaceAI…',
submitLoading: 'Invio del selfie e preparazione della ricerca…',
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
2026-04-12 19:31:12 +02:00
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.',
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
matchesLabel: 'Foto trovate',
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
noFileCta: 'Seleziona unimmagine per sbloccare la ricerca.',
invalidImage: 'Seleziona un file immagine valido.',
pollError: 'Impossibile leggere lo stato della ricerca.',
searchFailed: 'La ricerca non è andata a buon fine.',
processorUnavailable: 'Il motore FaceAI non è disponibile in questo momento. Riprova tra poco.',
redirectError: 'Impossibile generare il link di ritorno.',
chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.',
raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.',
2026-04-12 19:31:12 +02:00
invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.',
searchCreateError: 'Impossibile avviare la ricerca.',
activeSearchExists: 'C\'e gia una ricerca in corso per questo utente. Attendi il completamento oppure ricarica la pagina.',
rateLimited: 'Hai avviato troppe ricerche in poco tempo. Attendi un momento e riprova.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
},
en: {
pageTitle: 'Face ID',
pageHeadline: 'Find your photos with a selfie',
pageIntro: 'Upload a recent picture of yourself and Face ID will search only within the current race.',
userFallback: 'FaceAI session',
raceFallback: 'Current race',
statusReady: 'Ready',
statusProcessing: 'Processing',
statusCompleted: 'Completed',
statusFailed: 'Error',
statusLabel: 'Status',
userLabel: 'User',
raceLabel: 'Race',
languageLabel: 'Language',
uploaderTitle: 'Upload your selfie',
uploaderHint: 'Drag an image here or choose it from your device.',
uploaderDragIdle: 'Drag your selfie here',
uploaderDragActive: 'Drop the image to upload it',
uploaderBrowse: 'Choose image',
uploaderFormats: 'Supported formats: JPG, PNG, WEBP',
uploaderSelected: 'Selected file',
uploaderReplace: 'Replace',
uploaderRemove: 'Remove',
backButton: 'Back to the race page',
uploadButton: 'Start Face ID search',
openSimulator: 'Open the legacy simulator',
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
sessionLoading: 'Loading the FaceAI session…',
submitLoading: 'Uploading the selfie and preparing the search…',
redirectLoading: 'Redirecting to the filtered legacy page…',
processingLoading: 'Biometric search in progress across all race photos…',
unavailableDefault: 'FaceAI is not available for this race.',
2026-04-12 19:31:12 +02:00
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.',
readyMessage: 'Select a selfie to start a search limited to the current race.',
completedMessage: 'Search completed. Found {count} matching photos.',
failedMessage: 'The search did not complete. Check the message and try again.',
matchesLabel: 'Photos found',
redirectMessage: 'Redirecting to the filtered legacy page…',
noFileCta: 'Select an image to unlock the search action.',
invalidImage: 'Select a valid image file.',
pollError: 'Unable to read the search status.',
searchFailed: 'The search failed.',
processorUnavailable: 'The FaceAI processor is temporarily unavailable. Please try again shortly.',
redirectError: 'Unable to build the return link.',
chooseSelfie: 'Choose a selfie before starting the search.',
raceDataUnavailable: 'FaceAI data is not available for this race.',
2026-04-12 19:31:12 +02:00
invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.',
searchCreateError: 'Unable to start the search.',
activeSearchExists: 'There is already a search running for this user. Wait for it to finish or reload the page.',
rateLimited: 'Too many searches were started in a short time. Please wait a moment and try again.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Upload is not available for this race.'
}
};
const knownServerCodes = {
PROCESSOR_UNAVAILABLE: 'processorUnavailable',
ACTIVE_SEARCH_EXISTS: 'activeSearchExists',
RATE_LIMITED: 'rateLimited',
MISSING_SELFIE: 'chooseSelfie',
RACE_PKL_UNAVAILABLE: 'raceDataUnavailable',
RACE_DIRECTORY_NOT_FOUND: 'raceDataUnavailable',
MISSING_RACE_STORAGE: 'invalidRaceData'
};
const knownServerMessages = {
'No training dataset available for this race.': 'raceDataUnavailable',
'FaceAI data is not available for this race.': 'raceDataUnavailable',
'FaceAI is not available for this race.': 'unavailableDefault',
'Unable to read search status.': 'pollError',
'The search failed.': 'searchFailed',
'FaceAI processor is temporarily unavailable. Please try again shortly.': 'processorUnavailable',
'Unable to build return URL.': 'redirectError',
'Unable to create the search.': 'searchCreateError',
'Choose a selfie before starting the search.': 'chooseSelfie',
'There is already an operation being processed.': 'activeSearchExists',
'Too many search attempts. Please try again later.': 'rateLimited'
};
const simulatorUrl = legacyUrl('/faceai_simulator.php?raceId=101&lang=it');
const legacyHomeUrl = legacyUrl('/');
2026-04-12 19:31:12 +02:00
function isInvalidRaceAvailability(availability) {
return availability?.reasonCode === 'MISSING_RACE_STORAGE';
2026-04-12 19:31:12 +02:00
}
function buildLegacyReturnUrl(url) {
if (!url) {
return legacyHomeUrl;
}
try {
return new URL(url, window.location.href).toString();
} catch {
return legacyHomeUrl;
}
}
export function useFaceAiHome() {
const session = ref(null);
const loading = ref(true);
const errorMessage = ref('');
const selectedFile = ref(null);
const activeSearch = ref(null);
const redirectUrl = ref('');
const isSubmitting = ref(false);
const isRedirecting = ref(false);
const isDragging = ref(false);
const fileInput = ref(null);
let pollTimer = null;
let dragDepth = 0;
const currentLocale = computed(() => {
const language = (session.value?.lang || document.documentElement.lang || 'it').toLowerCase();
return language.startsWith('en') ? 'en' : 'it';
});
function t(key, params = {}) {
const message = copy[currentLocale.value][key] || copy.it[key] || key;
return Object.keys(params).reduce((text, paramKey) => text.replace(`{${paramKey}}`, String(params[paramKey])), message);
}
function localizeServerMessage(message, fallbackKey) {
if (!message) {
return t(fallbackKey);
}
if (currentLocale.value === 'en') {
return message;
}
const mappedKey = knownServerMessages[message];
if (mappedKey) {
return t(mappedKey);
}
return t(fallbackKey);
}
function localizeServerError(payload, fallbackKey) {
const mappedCodeKey = payload?.code ? knownServerCodes[payload.code] : null;
if (mappedCodeKey) {
return t(mappedCodeKey);
}
return localizeServerMessage(payload?.error, fallbackKey);
}
2026-04-12 19:31:12 +02:00
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
const mappedCodeKey = availability?.reasonCode ? knownServerCodes[availability.reasonCode] : null;
if (mappedCodeKey) {
return t(mappedCodeKey);
2026-04-12 19:31:12 +02:00
}
return localizeServerMessage(availability?.message, fallbackKey);
}
function shouldLogFaceAiDebug() {
return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
}
function logFaceAiDebug(label, extra = null) {
if (!shouldLogFaceAiDebug()) {
return;
}
const payload = {
pageUrl: window.location.href,
session: session.value,
availability: raceAvailability.value,
activeSearch: activeSearch.value,
redirectUrl: redirectUrl.value,
extra
};
console.groupCollapsed(`[FaceAI] ${label}`);
console.log(payload);
console.groupEnd();
}
2026-04-12 19:31:12 +02:00
function reportInvalidRaceAvailability(availability) {
if (!isInvalidRaceAvailability(availability)) {
return;
}
const details = {
raceId: session.value?.race?.id || null,
raceName: session.value?.race?.name || null,
lang: session.value?.lang || currentLocale.value,
reasonCode: availability.reasonCode,
message: availability.message,
storage: availability.storage || null,
raceDir: availability.raceDir || null
};
console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`);
}
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
const raceAvailability = computed(() => session.value?.availability || null);
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
const canStartSearch = computed(() => {
if (!session.value || !selectedFile.value) {
return false;
}
if (!session.value.access?.faceAiAllowed) {
return false;
}
return raceAvailability.value?.available === true && !isWorking.value;
});
const selectedFileSizeLabel = computed(() => {
if (!selectedFile.value?.size) {
return '';
}
const sizeInMb = selectedFile.value.size / (1024 * 1024);
if (sizeInMb >= 1) {
return `${sizeInMb.toFixed(1)} MB`;
}
return `${Math.max(1, Math.round(selectedFile.value.size / 1024))} KB`;
});
const activeSearchStatusLabel = computed(() => {
const status = activeSearch.value?.status;
if (status === 'processing') {
return t('statusProcessing');
}
if (status === 'completed') {
return t('statusCompleted');
}
if (status === 'failed') {
return t('statusFailed');
}
return t('statusReady');
});
const busyLabel = computed(() => {
if (loading.value) {
return t('sessionLoading');
}
if (isSubmitting.value) {
return t('submitLoading');
}
if (isRedirecting.value) {
return t('redirectLoading');
}
if (activeSearch.value?.status === 'processing') {
return t('processingLoading');
}
return '';
});
const statusLabel = computed(() => {
if (!activeSearch.value) {
if (session.value && raceAvailability.value && !raceAvailability.value.available) {
2026-04-12 19:31:12 +02:00
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
}
return t('readyMessage');
}
if (activeSearch.value.status === 'completed') {
2026-04-12 19:31:12 +02:00
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
return t('noFacesFoundMessage');
}
return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 });
}
if (activeSearch.value.status === 'failed') {
return localizeServerMessage(activeSearch.value.errorMessage, 'failedMessage');
}
return t('processingLoading');
});
function openFilePicker() {
if (!canPickFile.value) {
return;
}
fileInput.value?.click();
}
function setSelectedFile(file) {
if (!file) {
selectedFile.value = null;
return;
}
if (!file.type || !file.type.startsWith('image/')) {
selectedFile.value = null;
errorMessage.value = t('invalidImage');
if (fileInput.value) {
fileInput.value.value = '';
}
return;
}
selectedFile.value = file;
errorMessage.value = '';
}
function clearSelectedFile() {
selectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
function onFileChange(event) {
setSelectedFile(event.target.files?.[0] || null);
}
function onDragEnter(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth += 1;
isDragging.value = true;
}
function onDragOver(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
isDragging.value = true;
}
function onDragLeave(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
isDragging.value = false;
}
}
function onDrop(event) {
if (!canPickFile.value) {
return;
}
event.preventDefault();
dragDepth = 0;
isDragging.value = false;
setSelectedFile(event.dataTransfer?.files?.[0] || null);
}
async function loadSession() {
loading.value = true;
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
2026-04-12 19:31:12 +02:00
const payload = await response.json().catch(() => ({}));
loading.value = false;
2026-04-12 19:31:12 +02:00
logFaceAiDebug('Session load failed', { status: response.status, payload });
if (response.status === 401 || response.status === 403) {
window.location.replace(payload.redirectUrl || legacyHomeUrl);
}
return;
}
session.value = await response.json();
loading.value = false;
2026-04-12 19:31:12 +02:00
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
reportInvalidRaceAvailability(session.value.availability);
}
logFaceAiDebug('Session loaded');
}
async function pollSearch(searchId) {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
errorMessage.value = localizeServerError(payload, 'pollError');
isSubmitting.value = false;
logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload });
return;
}
activeSearch.value = await response.json();
logFaceAiDebug('Search status updated', { searchId, status: activeSearch.value.status });
if (activeSearch.value.status === 'failed') {
isSubmitting.value = false;
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
return;
}
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
2026-04-12 19:31:12 +02:00
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
isRedirecting.value = false;
redirectUrl.value = '';
clearSelectedFile();
logFaceAiDebug('Search completed without detectable faces', { searchId });
return;
}
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
errorMessage.value = localizeServerError(payload, 'redirectError');
logFaceAiDebug('Redirect build failed', { searchId, payload });
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
window.setTimeout(() => {
window.location.href = payload.url;
}, 1200);
return;
}
pollTimer = window.setTimeout(() => {
pollSearch(searchId);
}, 1500);
}
async function submitSearch() {
errorMessage.value = '';
redirectUrl.value = '';
isRedirecting.value = false;
if (!selectedFile.value) {
errorMessage.value = t('chooseSelfie');
return;
}
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
2026-04-12 19:31:12 +02:00
errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable');
return;
}
isSubmitting.value = true;
const formData = new FormData();
formData.set('raceId', session.value.race.id);
formData.set('selfie', selectedFile.value);
const response = await fetch('/api/searches', {
method: 'POST',
credentials: 'include',
body: formData
});
const payload = await response.json();
if (!response.ok) {
errorMessage.value = localizeServerError(payload, 'searchCreateError');
isSubmitting.value = false;
logFaceAiDebug('Search creation failed', { status: response.status, payload });
return;
}
activeSearch.value = payload;
logFaceAiDebug('Search created', { payload });
pollSearch(payload.id);
}
function returnToLegacy() {
const returnUrl = buildLegacyReturnUrl(session.value?.returnUrl);
logFaceAiDebug('Returning to legacy race page', { returnUrl });
window.location.replace(returnUrl);
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
});
return {
activeSearch,
activeSearchStatusLabel,
busyLabel,
canPickFile,
canStartSearch,
clearSelectedFile,
currentLocale,
errorMessage,
fileInput,
isDragging,
isProcessingSearch,
isRedirecting,
isSubmitting,
isWorking,
loading,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onFileChange,
openFilePicker,
redirectUrl,
returnToLegacy,
selectedFile,
selectedFileSizeLabel,
session,
simulatorUrl,
statusLabel,
submitSearch,
t
};
}