Refactor code structure for improved readability and maintainability

This commit is contained in:
MaddoScientisto 2026-04-12 17:26:17 +02:00
commit c71e4b4cd0
27 changed files with 1738 additions and 324 deletions

View file

@ -0,0 +1,56 @@
<script setup>
defineProps({
statusLabel: {
type: String,
required: true
},
isWorking: {
type: Boolean,
required: true
},
busyLabel: {
type: String,
required: true
},
activeSearch: {
type: Object,
default: null
},
redirectUrl: {
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
},
t: {
type: Function,
required: true
}
});
</script>
<template>
<div class="faceai-feedback mt-4">
<p class="lead mb-2">{{ statusLabel }}</p>
<p v-if="activeSearch" class="mb-2">{{ t('matchesLabel') }}: {{ activeSearch.matchCount }}</p>
<p v-if="redirectUrl" class="mb-2">{{ t('redirectMessage') }}</p>
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
</div>
</template>
<style scoped>
.faceai-feedback {
border-radius: 24px;
padding: 1.25rem 1.5rem;
background: linear-gradient(180deg, #fffdf9, #f5efe5);
border: 1px solid rgba(212, 189, 154, 0.55);
}
@media (max-width: 991.98px) {
.faceai-feedback {
padding: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,110 @@
<script setup>
defineProps({
session: {
type: Object,
default: null
},
currentLocale: {
type: String,
required: true
},
activeSearchStatusLabel: {
type: String,
required: true
},
t: {
type: Function,
required: true
}
});
</script>
<template>
<div class="faceai-hero card border-0 shadow-sm">
<div class="faceai-hero-body">
<p class="faceai-kicker mb-2">{{ t('pageTitle') }}</p>
<h1 class="faceai-title mb-3">{{ t('pageHeadline') }}</h1>
<p class="faceai-intro mb-4">{{ t('pageIntro') }}</p>
<div class="faceai-summary-grid">
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('raceLabel') }}</span>
<strong>{{ session ? session.race.name : t('raceFallback') }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('languageLabel') }}</span>
<strong>{{ session ? session.lang.toUpperCase() : currentLocale.toUpperCase() }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('statusLabel') }}</span>
<strong>{{ activeSearchStatusLabel }}</strong>
</div>
<div class="faceai-summary-pill">
<span class="faceai-summary-label">{{ t('userLabel') }}</span>
<strong>{{ session ? session.user.displayName : t('userFallback') }}</strong>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.faceai-hero {
overflow: hidden;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(244, 190, 92, 0.28), transparent 32%),
linear-gradient(135deg, #fffaf1 0%, #f3ebdc 100%);
}
.faceai-hero-body {
padding: 2rem;
}
.faceai-kicker {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #9a6a19;
}
.faceai-title {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.05;
color: #2d241c;
}
.faceai-intro {
color: #665548;
}
.faceai-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.9rem;
}
.faceai-summary-pill {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(191, 158, 117, 0.28);
}
.faceai-summary-label {
display: block;
margin-bottom: 0.2rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8b775f;
}
@media (max-width: 991.98px) {
.faceai-hero-body {
padding: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,394 @@
<script setup>
defineProps({
loading: {
type: Boolean,
required: true
},
isWorking: {
type: Boolean,
required: true
},
isProcessingSearch: {
type: Boolean,
required: true
},
session: {
type: Object,
default: null
},
simulatorUrl: {
type: String,
required: true
},
busyLabel: {
type: String,
required: true
},
canPickFile: {
type: Boolean,
required: true
},
isDragging: {
type: Boolean,
required: true
},
selectedFile: {
type: Object,
default: null
},
selectedFileSizeLabel: {
type: String,
required: true
},
canStartSearch: {
type: Boolean,
required: true
},
fileInput: {
type: Object,
required: true
},
t: {
type: Function,
required: true
}
});
const emit = defineEmits([
'open-file-picker',
'file-change',
'drag-enter',
'drag-over',
'drag-leave',
'drop',
'clear-file',
'submit-search'
]);
</script>
<template>
<section class="faceai-panel shadow-sm">
<div v-if="loading" class="faceai-loading-state">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<div v-else-if="!session" class="faceai-empty-state">
<h2 class="faceai-section-title">{{ t('pageTitle') }}</h2>
<p class="mb-3">{{ t('handoffMissing') }}</p>
<a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a>
</div>
<template v-else>
<div class="faceai-panel-header">
<div>
<h2 class="faceai-section-title mb-2">{{ t('uploaderTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p>
</div>
</div>
<div v-if="isWorking && busyLabel" class="faceai-busy-banner" aria-live="polite">
<span class="faceai-spinner" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<div
class="faceai-dropzone"
:class="{
'is-dragging': isDragging,
'is-disabled': !canPickFile,
'has-file': selectedFile,
'is-processing': isProcessingSearch
}"
@click="emit('open-file-picker')"
@dragenter="emit('drag-enter', $event)"
@dragover="emit('drag-over', $event)"
@dragleave="emit('drag-leave', $event)"
@drop="emit('drop', $event)"
>
<input
ref="fileInput"
class="d-none"
type="file"
accept="image/*"
:disabled="!canPickFile"
@change="emit('file-change', $event)"
/>
<div class="faceai-dropzone-inner">
<div class="faceai-dropzone-icon">
<i class="fa fa-cloud-upload" aria-hidden="true"></i>
</div>
<template v-if="selectedFile">
<p class="faceai-dropzone-title mb-2">{{ t('uploaderSelected') }}</p>
<strong class="faceai-file-name">{{ selectedFile.name }}</strong>
<p class="faceai-file-meta mb-0">{{ selectedFileSizeLabel }}</p>
</template>
<template v-else>
<p class="faceai-dropzone-title mb-2">
{{ isDragging ? t('uploaderDragActive') : t('uploaderDragIdle') }}
</p>
<p class="faceai-dropzone-copy mb-0">
{{ canPickFile ? t('uploaderFormats') : t('dropzoneDisabled') }}
</p>
</template>
</div>
<div class="faceai-dropzone-actions" @click.stop>
<button class="btn btn-outline-warning" type="button" :disabled="!canPickFile" @click="emit('open-file-picker')">
{{ selectedFile ? t('uploaderReplace') : t('uploaderBrowse') }}
</button>
<button v-if="selectedFile" class="btn btn-link" type="button" @click="emit('clear-file')">
{{ t('uploaderRemove') }}
</button>
</div>
<div v-if="isDragging" class="faceai-dropzone-overlay">
<span>{{ t('uploaderDragActive') }}</span>
</div>
<div v-if="isProcessingSearch" class="faceai-processing-overlay" aria-live="polite" aria-busy="true">
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
<strong>{{ busyLabel }}</strong>
</div>
</div>
<div class="faceai-action-row">
<button v-if="selectedFile" class="btn btn-warning" type="button" :disabled="!canStartSearch" @click="emit('submit-search')">
{{ t('uploadButton') }}
</button>
<a class="btn btn-light" :href="session.returnUrl">{{ t('backButton') }}</a>
</div>
<div v-if="!selectedFile && canPickFile" class="faceai-subtle-note">
{{ t('noFileCta') }}
</div>
</template>
</section>
</template>
<style scoped>
.faceai-panel {
border-radius: 28px;
padding: 1.5rem;
background: #fffdf9;
border: 1px solid rgba(212, 189, 154, 0.55);
}
.faceai-panel-header {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.faceai-section-title {
margin: 0;
font-size: 1.45rem;
color: #30261e;
}
.faceai-panel-subtitle,
.faceai-dropzone-copy,
.faceai-subtle-note {
color: #665548;
}
.faceai-busy-banner {
display: inline-flex;
align-items: center;
gap: 0.65rem;
margin-bottom: 1rem;
padding: 0.8rem 1rem;
border-radius: 999px;
background: rgba(255, 248, 224, 0.95);
color: #744500;
border: 1px solid rgba(213, 138, 0, 0.24);
}
.faceai-spinner {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid rgba(191, 158, 117, 0.3);
border-top-color: #c87800;
animation: faceai-spin 0.75s linear infinite;
}
.faceai-spinner-lg {
width: 2.75rem;
height: 2.75rem;
border-width: 4px;
}
.faceai-loading-state,
.faceai-empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 1rem 0.25rem;
}
.faceai-dropzone {
position: relative;
border-radius: 24px;
border: 2px dashed rgba(187, 144, 72, 0.55);
background: linear-gradient(180deg, rgba(255, 248, 235, 0.95), rgba(252, 244, 230, 0.98));
min-height: 280px;
padding: 1.5rem;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.faceai-dropzone.is-processing {
overflow: hidden;
}
.faceai-dropzone:hover {
transform: translateY(-1px);
box-shadow: 0 18px 38px rgba(93, 72, 44, 0.08);
}
.faceai-dropzone.is-dragging {
border-color: #d58a00;
background: linear-gradient(180deg, #fff4d7, #ffe7a8);
box-shadow: 0 20px 45px rgba(213, 138, 0, 0.18);
}
.faceai-dropzone.is-disabled {
cursor: not-allowed;
opacity: 0.7;
}
.faceai-dropzone.has-file {
border-style: solid;
background: linear-gradient(180deg, #fffaf0, #f8efe0);
}
.faceai-dropzone-inner {
min-height: 190px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.faceai-dropzone-icon {
width: 88px;
height: 88px;
margin-bottom: 1rem;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.9);
color: #b87014;
font-size: 2rem;
box-shadow: inset 0 0 0 1px rgba(191, 158, 117, 0.24);
}
.faceai-dropzone-title {
font-size: 1.2rem;
font-weight: 700;
color: #2d241c;
}
.faceai-file-name {
display: block;
font-size: 1.05rem;
color: #2d241c;
word-break: break-word;
}
.faceai-file-meta {
color: #7b6857;
}
.faceai-dropzone-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
}
.faceai-dropzone-overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 22px;
background: rgba(255, 236, 184, 0.84);
color: #764300;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.faceai-processing-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.9rem;
border-radius: 22px;
background: rgba(255, 249, 238, 0.9);
color: #5e3800;
text-align: center;
padding: 1.5rem;
backdrop-filter: blur(2px);
}
.faceai-action-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.faceai-subtle-note {
margin-top: 0.85rem;
font-size: 0.95rem;
}
@media (max-width: 991.98px) {
.faceai-panel {
padding: 1.25rem;
}
}
@media (max-width: 767.98px) {
.faceai-dropzone {
min-height: 240px;
padding: 1rem;
}
.faceai-action-row {
flex-direction: column;
}
.faceai-action-row .btn,
.faceai-empty-state .btn {
width: 100%;
}
.faceai-dropzone-actions {
flex-direction: column;
}
.faceai-dropzone-actions .btn {
width: 100%;
}
}
@keyframes faceai-spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,9 +1,19 @@
<script setup>
import { ref } from 'vue';
import { legacyAsset } from '../legacyAssets.js';
const logoUrl = legacyAsset('/images/layout/regalami-un-sorriso-ets-640.png');
const facebookUrl = legacyAsset('/images/FB-f-Logo__blue_29.png');
const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
const isMenuOpen = ref(false);
function toggleMenu() {
isMenuOpen.value = !isMenuOpen.value;
}
function closeMenu() {
isMenuOpen.value = false;
}
</script>
<template>
@ -14,25 +24,32 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
<a class="navbar-brand" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">
<img :src="logoUrl" alt="Regalami Un Sorriso ETS" width="100" />
</a>
<button class="navbar-toggler navbar-toggler-right" type="button">
<button
class="navbar-toggler navbar-toggler-right"
type="button"
aria-controls="navbarResponsive"
:aria-expanded="isMenuOpen ? 'true' : 'false'"
aria-label="Toggle navigation"
@click="toggleMenu"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse show" id="navbarResponsive">
<div :class="['collapse', 'navbar-collapse', { show: isMenuOpen }]" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/index.jsp">Home</a>
<a class="nav-link" href="http://localhost:8080/index.jsp" @click="closeMenu">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="http://localhost:8080/associazione.jsp">Associazione</a>
<a class="nav-link" href="http://localhost:8080/associazione.jsp" @click="closeMenu">Associazione</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Foto</a>
<a class="nav-link active" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it" @click="closeMenu">Foto</a>
</li>
<li class="nav-item">
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php">Archivio</a>
<a class="nav-link btn btn-sm btn-warning" href="http://localhost:8080/gallery2.php" @click="closeMenu">Archivio</a>
</li>
<li class="nav-item">
<a href="http://localhost:8080/dettaglio_clienti-it.html">
<a href="http://localhost:8080/dettaglio_clienti-it.html" @click="closeMenu">
<img :src="donateUrl" border="0" alt="PayPal" />
</a>
</li>
@ -40,12 +57,12 @@ const donateUrl = legacyAsset('/images/btn_donateCC_LG.gif');
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link active" href="#">
<a class="nav-link active" href="#" @click="closeMenu">
<i class="fa fa-user" aria-hidden="true"></i> Il mio account
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/">
<a class="nav-link" href="https://it-it.facebook.com/pg/Regalami-un-sorriso-ETS-189377806523/community/" @click="closeMenu">
<img :src="facebookUrl" class="img-fluid" alt="Facebook" />
</a>
</li>

View file

@ -0,0 +1,478 @@
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 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.',
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.',
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
};
}

View file

@ -1,150 +1,39 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import LegacyHeader from '../components/LegacyHeader.vue';
import { legacyAsset } from '../legacyAssets.js';
import FaceAiFeedbackPanel from '../components/FaceAiFeedbackPanel.vue';
import FaceAiHeroCard from '../components/FaceAiHeroCard.vue';
import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue';
import { useFaceAiHome } from '../composables/useFaceAiHome.js';
const coverImageUrl = legacyAsset('/images/layout/Logo_RUS_ETS_tricolore_3-1.jpg');
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);
let pollTimer = null;
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const busyLabel = computed(() => {
if (loading.value) {
return 'Caricamento sessione FaceAI...';
}
if (isSubmitting.value) {
return 'Invio del selfie e preparazione della ricerca...';
}
if (isRedirecting.value) {
return 'Reindirizzamento alla pagina legacy filtrata in corso...';
}
if (activeSearch.value?.status === 'processing') {
return 'Ricerca biometrica in corso su tutte le foto della gara...';
}
return '';
});
const statusLabel = computed(() => {
if (!activeSearch.value) {
return 'Carica un selfie per avviare una ricerca limitata alla gara corrente.';
}
if (activeSearch.value.status === 'completed') {
return `Ricerca completata. Trovate ${activeSearch.value.matchCount} foto corrispondenti.`;
}
if (activeSearch.value.status === 'failed') {
return 'La ricerca non e stata completata. Verifica il messaggio di errore e riprova.';
}
return 'Ricerca in corso. Il sistema aggiorna automaticamente lo stato finche il risultato non e pronto.';
});
async function loadSession() {
loading.value = true;
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
loading.value = false;
return;
}
session.value = await response.json();
loading.value = false;
}
function onFileChange(event) {
selectedFile.value = event.target.files?.[0] || null;
}
async function pollSearch(searchId) {
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
if (!response.ok) {
errorMessage.value = 'Unable to read search status.';
isSubmitting.value = false;
return;
}
activeSearch.value = await response.json();
if (activeSearch.value.status === 'failed') {
isSubmitting.value = false;
errorMessage.value = activeSearch.value.errorMessage || 'The search failed.';
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 = payload.error || 'Unable to build return URL.';
return;
}
redirectUrl.value = payload.url;
isRedirecting.value = true;
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 = 'Choose a selfie before starting the search.';
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 = payload.error || 'Unable to create the search.';
isSubmitting.value = false;
return;
}
activeSearch.value = payload;
pollSearch(payload.id);
}
onMounted(loadSession);
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer);
}
});
const {
activeSearch,
activeSearchStatusLabel,
busyLabel,
canPickFile,
canStartSearch,
clearSelectedFile,
currentLocale,
errorMessage,
fileInput,
isDragging,
isProcessingSearch,
isWorking,
loading,
onDragEnter,
onDragLeave,
onDragOver,
onDrop,
onFileChange,
openFilePicker,
redirectUrl,
selectedFile,
selectedFileSizeLabel,
session,
simulatorUrl,
statusLabel,
submitSearch,
t
} = useFaceAiHome();
</script>
<template>
@ -152,92 +41,62 @@ onBeforeUnmount(() => {
<LegacyHeader />
<div class="container my-3 faceai-page">
<div class="row mb-5">
<div class="col-lg-12">
<h1 class="my-4">Face ID</h1>
</div>
<FaceAiHeroCard
:session="session"
:current-locale="currentLocale"
:active-search-status-label="activeSearchStatusLabel"
:t="t"
/>
<div class="col-md-2">
<img :src="coverImageUrl" class="img-fluid border border-warning" alt="FaceAI" />
</div>
<div class="col-md-10">
<div class="row riepilogo">
<div class="col-md-3">
<p><i class="fa fa-user fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.user.displayName : 'Sessione FaceAI' }}</p>
</div>
<div class="col-md-3">
<p><i class="fa fa-camera-retro fa-lg text-warning" aria-hidden="true"></i> {{ session ? session.race.name : 'Upload selfie' }}</p>
</div>
<div class="col">
<p><i class="fa fa-refresh fa-lg text-warning" aria-hidden="true"></i> {{ activeSearch ? activeSearch.status : 'ready' }}</p>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="bg-light faceai-form-shell">
<div class="row">
<div class="form-group mx-3 pt-4 pb-1 mb-0 px-2 arrow_box">
<h2>Cerca le tue foto</h2>
</div>
<div v-if="loading" class="col-12 p-4">
<div class="faceai-spinner-block">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
</div>
<div v-else-if="!session" class="col-12 p-4">
<p>Apri prima il simulatore legacy per generare il token firmato di handoff.</p>
<a class="btn btn-warning" href="http://localhost:8080/faceai_simulator.php?raceId=101&lang=it">Apri il simulatore legacy</a>
</div>
<template v-else>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="raceName" class="sr-only">Gara</label>
<input id="raceName" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.race.name" readonly />
</div>
</div>
<div class="form-group col-12 col-md-3 mt-3 ml-3">
<div class="input-group">
<label for="langView" class="sr-only">Lingua</label>
<input id="langView" class="form-control form-control-sm mb-2 mb-sm-0" :value="session.lang" readonly />
</div>
</div>
<div class="form-group col-12 col-md-4 mt-3 ml-3">
<div class="input-group">
<label for="selfieUpload" class="sr-only">Selfie</label>
<input id="selfieUpload" class="form-control form-control-sm mb-2 mb-sm-0" type="file" accept="image/*" @change="onFileChange" />
</div>
</div>
<div class="form-group col-12 mt-2 ml-3 mr-3 faceai-action-row">
<button class="btn btn-warning" type="button" :disabled="isWorking" @click="submitSearch">Avvia ricerca Face ID</button>
<a class="btn btn-light" :href="session.returnUrl">Torna alla pagina gara</a>
</div>
</template>
</div>
</div>
</div>
</div>
<div class="faceai-feedback mt-4">
<p class="lead mb-2">{{ statusLabel }}</p>
<div v-if="isWorking && busyLabel" class="faceai-spinner-block mb-3">
<span class="spinner-border spinner-border-sm text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<p v-if="activeSearch" class="mb-2">Match trovati: {{ activeSearch.matchCount }}</p>
<p v-if="redirectUrl" class="mb-2">Reindirizzamento alla pagina legacy filtrata in corso...</p>
<p v-if="errorMessage" class="text-danger mb-2">{{ errorMessage }}</p>
</div>
<div class="row mt-4">
<div class="col-12">
<FaceAiUploadPanel
:loading="loading"
:is-working="isWorking"
:is-processing-search="isProcessingSearch"
:session="session"
:simulator-url="simulatorUrl"
:busy-label="busyLabel"
:can-pick-file="canPickFile"
:is-dragging="isDragging"
:selected-file="selectedFile"
:selected-file-size-label="selectedFileSizeLabel"
:can-start-search="canStartSearch"
:file-input="fileInput"
:t="t"
@open-file-picker="openFilePicker"
@file-change="onFileChange"
@drag-enter="onDragEnter"
@drag-over="onDragOver"
@drag-leave="onDragLeave"
@drop="onDrop"
@clear-file="clearSelectedFile"
@submit-search="submitSearch"
/>
</div>
</div>
<FaceAiFeedbackPanel
:status-label="statusLabel"
:is-working="isWorking"
:busy-label="busyLabel"
:active-search="activeSearch"
:redirect-url="redirectUrl"
:error-message="errorMessage"
:t="t"
/>
</div>
</main>
</template>
<style scoped>
.faceai-page {
padding-bottom: 2rem;
}
@media (max-width: 767.98px) {
.faceai-page {
padding-bottom: 1.25rem;
}
}
</style>