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 }; }