478 lines
15 KiB
JavaScript
478 lines
15 KiB
JavaScript
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|||
|
|
|
|||
|
|
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 l’immagine 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.',
|
|||
|
|
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 un’immagine 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.',
|
|||
|
|
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.',
|
|||
|
|
searchCreateError: 'Impossibile avviare la ricerca.',
|
|||
|
|
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.',
|
|||
|
|
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.',
|
|||
|
|
redirectError: 'Unable to build the return link.',
|
|||
|
|
chooseSelfie: 'Choose a selfie before starting the search.',
|
|||
|
|
raceDataUnavailable: 'FaceAI data is not available for this race.',
|
|||
|
|
searchCreateError: 'Unable to start the search.',
|
|||
|
|
faceAiAlt: 'FaceAI',
|
|||
|
|
dropzoneDisabled: 'Upload is not available for this race.'
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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',
|
|||
|
|
'Unable to build return URL.': 'redirectError',
|
|||
|
|
'Unable to create the search.': 'searchCreateError',
|
|||
|
|
'Choose a selfie before starting the search.': 'chooseSelfie'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it';
|
|||
|
|
|
|||
|
|
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 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();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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) {
|
|||
|
|
return localizeServerMessage(raceAvailability.value.message, 'unavailableDefault');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return t('readyMessage');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (activeSearch.value.status === 'completed') {
|
|||
|
|
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) {
|
|||
|
|
loading.value = false;
|
|||
|
|
logFaceAiDebug('Session load failed', { status: response.status });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
session.value = await response.json();
|
|||
|
|
loading.value = false;
|
|||
|
|
logFaceAiDebug('Session loaded');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function pollSearch(searchId) {
|
|||
|
|
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
|||
|
|
if (!response.ok) {
|
|||
|
|
errorMessage.value = t('pollError');
|
|||
|
|
isSubmitting.value = false;
|
|||
|
|
logFaceAiDebug('Search polling failed', { searchId, status: response.status });
|
|||
|
|
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;
|
|||
|
|
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
|||
|
|
const payload = await redirectResponse.json();
|
|||
|
|
if (!redirectResponse.ok) {
|
|||
|
|
errorMessage.value = localizeServerMessage(payload.error, '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) {
|
|||
|
|
errorMessage.value = localizeServerMessage(raceAvailability.value?.message, '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 = localizeServerMessage(payload.error, 'searchCreateError');
|
|||
|
|
isSubmitting.value = false;
|
|||
|
|
logFaceAiDebug('Search creation failed', { status: response.status, payload });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeSearch.value = payload;
|
|||
|
|
logFaceAiDebug('Search created', { payload });
|
|||
|
|
pollSearch(payload.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
selectedFile,
|
|||
|
|
selectedFileSizeLabel,
|
|||
|
|
session,
|
|||
|
|
simulatorUrl,
|
|||
|
|
statusLabel,
|
|||
|
|
submitSearch,
|
|||
|
|
t
|
|||
|
|
};
|
|||
|
|
}
|