All checks were successful
Publish FaceAI Container / publish (push) Successful in 5m20s
- Updated `run_face_encoder.bat` to include remote execution parameters for SSH and SCP. - Refactored `run_face_encoder.ps1` to accept remote execution parameters and handle remote file operations. - Modified `FaceAiUploadPanel.vue` to introduce consent management UI and error handling for race availability. - Enhanced `useFaceAiHome.js` to manage consent acceptance and integrate cookie handling for biometric data processing notice. - Updated `HomeView.vue` to streamline the upload panel and integrate consent handling logic. Co-authored-by: Copilot <copilot@github.com>
722 lines
No EOL
24 KiB
JavaScript
722 lines
No EOL
24 KiB
JavaScript
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||
import { legacyUrl } from '../legacyUrls.js';
|
||
|
||
const FACEAI_DISCLAIMER_COOKIE = 'faceai_biometric_notice_v1';
|
||
const FACEAI_DISCLAIMER_COOKIE_MAX_AGE = 60 * 60 * 24 * 180;
|
||
|
||
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',
|
||
uploaderReadyTitle: 'Carica il tuo selfie',
|
||
uploaderProcessingTitle: 'Ricerca in corso',
|
||
uploaderErrorTitle: 'Ricerca non completata',
|
||
uploaderUnavailableTitle: 'Face ID non disponibile',
|
||
uploaderConsentTitle: 'Prima di continuare',
|
||
backButton: 'Torna alla pagina gara',
|
||
retryButton: 'Riprova',
|
||
agreeButton: 'Accetto e continuo',
|
||
consentCheckbox: 'Confermo di aver letto l’informativa sul trattamento dei dati biometrici.',
|
||
disclaimerBody: 'Trattamento dati biometrici per ricerca facciale\nI dati biometrici sono trattati nel rispetto del GDPR esclusivamente per la ricerca e conservati solo per il tempo strettamente necessario alla ricerca (30” al massimo). Non vengono ceduti a terzi né utilizzati per finalità pubblicitarie.',
|
||
consentRequired: 'Per continuare devi accettare l’informativa.',
|
||
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: 'Apertura della pagina gara filtrata…',
|
||
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
|
||
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
|
||
noFacesFoundMessage: 'Non sono state trovate foto corrispondenti con il selfie caricato. Puoi tornare alla gara oppure riprovare con un’altra immagine.',
|
||
readyMessage: 'Seleziona un selfie per avviare subito la ricerca nella 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 dal dispositivo o trascinala qui per iniziare.',
|
||
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.',
|
||
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',
|
||
uploaderReadyTitle: 'Upload your selfie',
|
||
uploaderProcessingTitle: 'Search in progress',
|
||
uploaderErrorTitle: 'Search not completed',
|
||
uploaderUnavailableTitle: 'Face ID unavailable',
|
||
uploaderConsentTitle: 'Before you continue',
|
||
backButton: 'Back to the race page',
|
||
retryButton: 'Try again',
|
||
agreeButton: 'I agree and continue',
|
||
consentCheckbox: 'I confirm that I have read the biometric data processing notice.',
|
||
disclaimerBody: 'Biometric data processing for facial search\nBiometric data is processed in compliance with GDPR exclusively for facial search and kept only for the time strictly necessary to perform the search (30 seconds maximum). It is not shared with third parties and is not used for advertising purposes.',
|
||
consentRequired: 'You must accept the notice before continuing.',
|
||
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: 'Opening the filtered race page…',
|
||
processingLoading: 'Biometric search in progress across all race photos…',
|
||
unavailableDefault: 'FaceAI is not available for this race.',
|
||
noFacesFoundMessage: 'No matching photos were found for the uploaded selfie. You can go back to the race page or try another image.',
|
||
readyMessage: 'Select a selfie to start the search immediately for 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 from your device or drag it here to begin.',
|
||
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.',
|
||
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('/');
|
||
|
||
function isInvalidRaceAvailability(availability) {
|
||
return availability?.reasonCode === 'MISSING_RACE_STORAGE';
|
||
}
|
||
|
||
function buildLegacyReturnUrl(url) {
|
||
if (!url) {
|
||
return legacyHomeUrl;
|
||
}
|
||
|
||
try {
|
||
return new URL(url, window.location.href).toString();
|
||
} catch {
|
||
return legacyHomeUrl;
|
||
}
|
||
}
|
||
|
||
function hasAcceptedDisclaimerCookie() {
|
||
if (typeof document === 'undefined') {
|
||
return false;
|
||
}
|
||
|
||
return document.cookie
|
||
.split(';')
|
||
.map((part) => part.trim())
|
||
.some((part) => part === `${FACEAI_DISCLAIMER_COOKIE}=1`);
|
||
}
|
||
|
||
function persistAcceptedDisclaimerCookie() {
|
||
if (typeof document === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
document.cookie = `${FACEAI_DISCLAIMER_COOKIE}=1; Max-Age=${FACEAI_DISCLAIMER_COOKIE_MAX_AGE}; Path=/; SameSite=Lax`;
|
||
}
|
||
|
||
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);
|
||
const consentAccepted = ref(hasAcceptedDisclaimerCookie());
|
||
const consentChecked = ref(false);
|
||
const panelErrorMode = ref('');
|
||
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);
|
||
}
|
||
|
||
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
|
||
const mappedCodeKey = availability?.reasonCode ? knownServerCodes[availability.reasonCode] : null;
|
||
if (mappedCodeKey) {
|
||
return t(mappedCodeKey);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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 raceAvailabilityMessage = computed(() => {
|
||
if (!raceAvailability.value || raceAvailability.value.available) {
|
||
return '';
|
||
}
|
||
|
||
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
|
||
});
|
||
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
|
||
const hasSearchFailure = computed(() => panelErrorMode.value === 'failed' || panelErrorMode.value === 'no-results');
|
||
const showConsent = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && session.value?.access?.faceAiAllowed && !consentAccepted.value);
|
||
const panelMode = computed(() => {
|
||
if (loading.value) {
|
||
return 'loading';
|
||
}
|
||
|
||
if (!session.value) {
|
||
return 'missing-session';
|
||
}
|
||
|
||
if (!session.value.access?.faceAiAllowed || raceAvailability.value?.available === false) {
|
||
return 'unavailable';
|
||
}
|
||
|
||
if (showConsent.value) {
|
||
return 'consent';
|
||
}
|
||
|
||
if (isProcessingSearch.value || isRedirecting.value) {
|
||
return 'processing';
|
||
}
|
||
|
||
if (hasSearchFailure.value) {
|
||
return 'error';
|
||
}
|
||
|
||
return 'upload';
|
||
});
|
||
|
||
const canStartSearch = computed(() => {
|
||
if (!session.value || !selectedFile.value) {
|
||
return false;
|
||
}
|
||
|
||
if (!consentAccepted.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 getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
|
||
}
|
||
|
||
return t('readyMessage');
|
||
}
|
||
|
||
if (activeSearch.value.status === 'completed') {
|
||
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 = '';
|
||
panelErrorMode.value = '';
|
||
|
||
if (canStartSearch.value) {
|
||
submitSearch();
|
||
}
|
||
}
|
||
|
||
function clearSelectedFile() {
|
||
selectedFile.value = null;
|
||
if (fileInput.value) {
|
||
fileInput.value.value = '';
|
||
}
|
||
}
|
||
|
||
function resetForRetry() {
|
||
errorMessage.value = '';
|
||
redirectUrl.value = '';
|
||
activeSearch.value = null;
|
||
isSubmitting.value = false;
|
||
isRedirecting.value = false;
|
||
panelErrorMode.value = '';
|
||
clearSelectedFile();
|
||
}
|
||
|
||
function setConsentChecked(value) {
|
||
consentChecked.value = Boolean(value);
|
||
if (errorMessage.value === t('consentRequired')) {
|
||
errorMessage.value = '';
|
||
}
|
||
}
|
||
|
||
function acceptDisclaimer() {
|
||
if (consentAccepted.value) {
|
||
return;
|
||
}
|
||
|
||
if (!consentChecked.value) {
|
||
errorMessage.value = t('consentRequired');
|
||
return;
|
||
}
|
||
|
||
consentAccepted.value = true;
|
||
errorMessage.value = '';
|
||
persistAcceptedDisclaimerCookie();
|
||
}
|
||
|
||
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) {
|
||
const payload = await response.json().catch(() => ({}));
|
||
loading.value = false;
|
||
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;
|
||
consentAccepted.value = hasAcceptedDisclaimerCookie();
|
||
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;
|
||
panelErrorMode.value = 'failed';
|
||
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');
|
||
panelErrorMode.value = 'failed';
|
||
return;
|
||
}
|
||
|
||
if (activeSearch.value.status === 'completed') {
|
||
isSubmitting.value = false;
|
||
if (activeSearch.value.completionCode === 'NO_FACES_FOUND' || Number(activeSearch.value.matchCount || 0) === 0) {
|
||
isRedirecting.value = false;
|
||
redirectUrl.value = '';
|
||
panelErrorMode.value = 'no-results';
|
||
clearSelectedFile();
|
||
errorMessage.value = t('noFacesFoundMessage');
|
||
logFaceAiDebug('Search completed without detectable faces', { searchId });
|
||
return;
|
||
}
|
||
|
||
errorMessage.value = '';
|
||
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
||
const payload = await redirectResponse.json();
|
||
if (!redirectResponse.ok) {
|
||
errorMessage.value = localizeServerError(payload, 'redirectError');
|
||
panelErrorMode.value = 'failed';
|
||
logFaceAiDebug('Redirect build failed', { searchId, payload });
|
||
return;
|
||
}
|
||
redirectUrl.value = payload.url;
|
||
isRedirecting.value = true;
|
||
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
|
||
window.location.replace(payload.url);
|
||
return;
|
||
}
|
||
|
||
pollTimer = window.setTimeout(() => {
|
||
pollSearch(searchId);
|
||
}, 1500);
|
||
}
|
||
|
||
async function submitSearch() {
|
||
errorMessage.value = '';
|
||
redirectUrl.value = '';
|
||
isRedirecting.value = false;
|
||
panelErrorMode.value = '';
|
||
|
||
if (!selectedFile.value) {
|
||
errorMessage.value = t('chooseSelfie');
|
||
return;
|
||
}
|
||
|
||
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
|
||
errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable');
|
||
return;
|
||
}
|
||
|
||
if (!consentAccepted.value) {
|
||
errorMessage.value = t('consentRequired');
|
||
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;
|
||
panelErrorMode.value = 'failed';
|
||
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,
|
||
consentAccepted,
|
||
consentChecked,
|
||
currentLocale,
|
||
errorMessage,
|
||
fileInput,
|
||
isDragging,
|
||
isProcessingSearch,
|
||
isRedirecting,
|
||
isSubmitting,
|
||
isWorking,
|
||
loading,
|
||
onDragEnter,
|
||
onDragLeave,
|
||
onDragOver,
|
||
onDrop,
|
||
onFileChange,
|
||
openFilePicker,
|
||
panelMode,
|
||
raceAvailabilityMessage,
|
||
resetForRetry,
|
||
redirectUrl,
|
||
returnToLegacy,
|
||
acceptDisclaimer,
|
||
selectedFile,
|
||
selectedFileSizeLabel,
|
||
session,
|
||
setConsentChecked,
|
||
simulatorUrl,
|
||
statusLabel,
|
||
submitSearch,
|
||
t
|
||
};
|
||
} |