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

@ -61,6 +61,7 @@ Use three deployable parts:
- Read the handoff token or FaceAI session cookie.
- Show the legacy-like header and navigation.
- Check whether the mounted FaceAI dataset exists for the selected race before enabling uploads.
- Let the user upload a selfie.
- Create a race-scoped search request.
- Poll job status or show queued state.
@ -72,7 +73,7 @@ Use three deployable parts:
- Receive a race-scoped search job.
- Queue requests and process them one by one.
- Run the external face-recognition program.
- Resolve `year/monthFolder/raceFolder` inside the mounted dataset root, take the first `.pkl` file in that race directory, and run the external face-recognition program against it.
- Return match results with confidence and photo ids or file identifiers.
- Return a completed result set usable by the legacy filter handoff.
@ -95,6 +96,10 @@ Instead:
- access flags for FaceAI
- race id
- race slug or descriptor
- race storage metadata needed to resolve the mounted FaceAI dataset:
- `year`
- `monthFolder` like `04.APRILE`
- `raceFolder` like `LIVORNO` or `PISA`
- current page URL as `returnUrl`
- expiry time, ideally 1 to 5 minutes
3. Browser is redirected to `https://faceai.regalamiunsorriso.it/auth/callback?token=...`
@ -138,7 +143,7 @@ The lowest-risk way to do that is to update `www/_js/rus-ecom-240621.js` so that
- removes that select from the rendered UI
- inserts a `Face ID` button in the same area
- builds the launch URL using the current race context and current page URL
- carries `raceId`, race description or slug, language, and exact `returnUrl`
- carries `raceId`, race description or slug, `raceYear`, `raceMonthFolder`, `raceFolder`, language, and exact `returnUrl`
This avoids fragile JSP layout edits and keeps the change deployable as a single JS asset update.
@ -172,7 +177,7 @@ This is preferable to putting the matched ids directly in the browser URL, becau
## FaceAI App Structure
The requested target folder is `faceai/`. It does not currently exist in this workspace, so this plan assumes it will be created as a new app.
The target folder is `faceai/`, and this workspace now contains an implemented scaffold there.
Suggested structure:
@ -198,10 +203,11 @@ faceai/
5. FaceAI shows a page styled like the old site, including a matching header and a clear `Back to race page` action.
6. User uploads a selfie.
7. FaceAI creates a search job with `userId`, `raceId`, `requestId`, and selfie file reference.
8. FaceAI polls until the processing job completes.
9. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`.
10. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow.
11. User opens and downloads photos exactly as they do today, through the legacy site.
8. FaceAI checks the mounted race directory immediately and, if no `.pkl` is present for that race, disables processing and offers only the return path.
9. FaceAI polls until the processing job completes.
10. Once the result is ready, FaceAI redirects the browser back to the original race page on `www`.
11. The legacy site resolves the matched photo ids and renders the race page filtered to those photos only, similar in spirit to the existing pettorale-based flow.
12. User opens and downloads photos exactly as they do today, through the legacy site.
## Result And Download Strategy
@ -255,6 +261,20 @@ For v1, `photoId` is the most important field. If the legacy page is the final r
Race scope is mandatory. The service must never search globally by default.
The mounted dataset layout is now assumed to be:
```text
/mounted-pkl-root/
2026/
04.APRILE/
PISA/
any-file-name.pkl
LIVORNO/
any-file-name.pkl
```
The `.pkl` filename does not matter. The first `.pkl` found at the race root is the one passed to the matcher.
## Async Processing Design
Use an API plus worker model.
@ -275,6 +295,7 @@ Input job:
- request id
- race id
- race storage metadata: `year`, `monthFolder`, `raceFolder`
- selfie storage path
- user id
- email
@ -370,7 +391,7 @@ This is safer than trying to embed the old JSP header directly into a Node app.
- Update `www/_js/rus-ecom-240621.js` to remove the dropdown from the UI and insert the FaceAI button.
- Add the legacy auth bridge endpoint.
- Pass `raceId`, `lang`, and `returnUrl` into the FaceAI launch.
- Pass `raceId`, `lang`, `returnUrl`, `raceYear`, `raceMonthFolder`, and `raceFolder` into the FaceAI launch.
- Add the legacy return endpoint or result-aware race filter path.
### Phase 3: FaceAI app shell

View file

@ -6,3 +6,9 @@ FACEAI_ENABLE_LOCAL_LEGACY_STATIC=1
FACEAI_LOCAL_LEGACY_STATIC_ROOT=k:\various\regalamiunsorriso\www
FACEAI_SHARED_SECRET=change-me
FACEAI_SESSION_COOKIE=rus_faceai_session
FACEAI_REDIS_URL=redis://redis:6379
FACEAI_QUEUE_NAME=faceai-searches
FACEAI_RUNTIME_ROOT=/data/runtime
FACEAI_UPLOAD_ROOT=/data/runtime/uploads
FACEAI_PKL_ROOT=/data/pkl
FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher

View file

@ -76,7 +76,7 @@ The checked-in `docker-compose.yml` starts:
The local stack also mounts:
- `../bin/Face_Recognition_Unix` into the processor container as the matcher binary source
- `../test_pkl` into the processor container as fallback PKL test data
- `../test_pkl` into both the public FaceAI container and the processor container as the shared read-only PKL dataset root
- `../www` into the PHP container so the real bridge files are used
### Run The Browser Test
@ -84,7 +84,7 @@ The local stack also mounts:
Open:
```text
http://localhost:8080/faceai_simulator.php?raceId=101&lang=it
http://localhost:8080/faceai_simulator.php?raceId=202&lang=it
```
That page simulates the legacy race page, loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the real PHP handoff bridge at `www/faceai_handoff.php`.
@ -160,9 +160,11 @@ services:
FACEAI_QUEUE_NAME: faceai-searches
FACEAI_RUNTIME_ROOT: /data/runtime
FACEAI_UPLOAD_ROOT: /data/runtime/uploads
FACEAI_PKL_ROOT: /data/pkl
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 0
volumes:
- faceai-runtime:/data/runtime
- /srv/faceai/pkl:/data/pkl:ro
ports:
- "127.0.0.1:3001:3001"
depends_on:
@ -230,11 +232,22 @@ Processor settings:
| Variable | Required | Example | Purpose |
| --- | --- | --- | --- |
| `FACEAI_PKL_ROOT` | yes | `/data/pkl` | mounted race-to-PKL dataset root |
| `FACEAI_TEST_PKL_ROOT` | optional | `/data/pkl/test` | local-only fallback PKL location |
| `FACEAI_MATCHER_BINARY` | yes | `/opt/face-recognition/face_matcher` | matcher executable inside the processor container |
| `FACEAI_WORKER_CONCURRENCY` | optional | `2` | BullMQ worker concurrency |
| `FACEAI_WORKER_TIMEOUT_MS` | optional | `300000` | matcher timeout in milliseconds |
The mounted PKL root is expected to use this structure:
```text
/data/pkl/
2026/
04.APRILE/
PISA/
any-file-name.pkl
```
The public FaceAI site mounts the same path read-only so it can check availability during session bootstrap and refuse uploads immediately when the race has no `.pkl` data.
Do not enable `FACEAI_ENABLE_LOCAL_LEGACY_STATIC` in production. That mode exists only for local simulator flows.
### Legacy-Side Configuration That Must Match
@ -276,7 +289,7 @@ This scaffold can now be deployed with the public site, processor, and Redis, bu
- search state is short-lived in Redis and is not backed by a durable database
- runtime uploads and matcher output still need an agreed production retention and cleanup policy
- the final production PKL/NAS layout is not yet locked down
- the PKL mount contract is now defined, but final NAS operations and cleanup policy still need to be hardened
- the backend currently sets the FaceAI session cookie with `secure: false`, which should be hardened before final public rollout
- the local simulator endpoints under `/dev/*` are still present in the app and should be treated as non-production scaffolding
- the processor CSV parser is still based on the current scaffolded matcher output assumptions
@ -299,7 +312,6 @@ FACEAI_QUEUE_NAME=faceai-searches
FACEAI_RUNTIME_ROOT=/data/runtime
FACEAI_UPLOAD_ROOT=/data/runtime/uploads
FACEAI_PKL_ROOT=/data/pkl
FACEAI_TEST_PKL_ROOT=/data/pkl/test
FACEAI_MATCHER_BINARY=/opt/face-recognition/face_matcher
```
@ -311,6 +323,14 @@ In the provided Docker Compose stack, that wiring is already done with:
FACEAI_LEGACY_RETURN_URL=http://localhost:8080/faceai_return.php
```
The local PHP simulator also needs the legacy bridge feature flag enabled:
```text
FACEAI_FEATURE_ENABLED=1
```
The checked-in `docker-compose.yml` now sets that on the `legacy-php` service so the simulator can launch the FaceAI handoff flow locally.
## Notes
- Search orchestration now uses Redis and a dedicated processor worker.

View file

@ -9,6 +9,7 @@ export const config = {
frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173',
publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001',
legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return',
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'
: process.env.NODE_ENV !== 'production',

View file

@ -0,0 +1,130 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const ITALIAN_MONTH_NAMES = [
'GENNAIO',
'FEBBRAIO',
'MARZO',
'APRILE',
'MAGGIO',
'GIUGNO',
'LUGLIO',
'AGOSTO',
'SETTEMBRE',
'OTTOBRE',
'NOVEMBRE',
'DICEMBRE'
];
function sanitizePathSegment(value) {
const normalized = String(value || '').trim();
if (!normalized) {
return '';
}
if (normalized === '.' || normalized === '..' || normalized.includes('..') || /[\\/]/.test(normalized)) {
throw new Error('Invalid race storage path segment');
}
return normalized;
}
export function normalizeRaceFolderName(value) {
return String(value || '')
.trim()
.replace(/[<>:"/\\|?*]/g, ' ')
.replace(/\s+/g, ' ')
.toUpperCase();
}
export function buildMonthFolder(year, monthIndex) {
const safeYear = sanitizePathSegment(year);
const normalizedMonthIndex = Number(monthIndex);
if (!safeYear || Number.isNaN(normalizedMonthIndex) || normalizedMonthIndex < 1 || normalizedMonthIndex > 12) {
return '';
}
return `${String(normalizedMonthIndex).padStart(2, '0')}.${ITALIAN_MONTH_NAMES[normalizedMonthIndex - 1]}`;
}
export function buildRaceStorage(storageInput = {}) {
const year = sanitizePathSegment(storageInput.year);
const monthFolder = sanitizePathSegment(storageInput.monthFolder);
const raceFolder = sanitizePathSegment(normalizeRaceFolderName(storageInput.raceFolder));
if (!year || !monthFolder || !raceFolder) {
return null;
}
return {
year,
monthFolder,
raceFolder,
relativeDir: path.posix.join(year, monthFolder, raceFolder)
};
}
export async function resolveRacePklAvailability({ pklRoot, race }) {
if (!pklRoot) {
return {
available: false,
reasonCode: 'PKL_ROOT_NOT_CONFIGURED',
message: 'The PKL root is not configured for this FaceAI environment.',
storage: null
};
}
const storage = buildRaceStorage(race?.storage || race);
if (!storage) {
return {
available: false,
reasonCode: 'MISSING_RACE_STORAGE',
message: 'The legacy handoff did not provide the folder metadata required to resolve FaceAI data for this race.',
storage: null
};
}
const raceDir = path.join(pklRoot, storage.year, storage.monthFolder, storage.raceFolder);
let entries;
try {
entries = await fs.readdir(raceDir, { withFileTypes: true });
} catch (error) {
if (error?.code === 'ENOENT') {
return {
available: false,
reasonCode: 'RACE_DIRECTORY_NOT_FOUND',
message: `No FaceAI dataset directory exists for ${storage.relativeDir}.`,
storage,
raceDir
};
}
throw error;
}
const pklEntry = entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.pkl'))
.sort((left, right) => left.name.localeCompare(right.name, 'en'))[0];
if (!pklEntry) {
return {
available: false,
reasonCode: 'PKL_FILE_NOT_FOUND',
message: `The race directory ${storage.relativeDir} exists, but it does not contain any .pkl file.`,
storage,
raceDir
};
}
return {
available: true,
reasonCode: null,
message: `Using ${storage.relativeDir}/${pklEntry.name}`,
storage,
raceDir,
pklPath: path.join(raceDir, pklEntry.name),
pklFileName: pklEntry.name
};
}

View file

@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import { signPayload, verifySignedPayload } from './auth.js';
import { createSession, getSession, mockCatalog } from './store.js';
import { buildRaceStorage, resolveRacePklAvailability } from './race-storage.js';
import {
acquireActiveSearchLock,
createRedisConnection,
@ -94,8 +95,24 @@ async function enforceSearchRateLimit(req, res, next) {
next();
}
function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
const race = mockCatalog[raceId] || { id: raceId, slug: raceSlug || `race-${raceId}`, name: raceSlug || `Race ${raceId}` };
function normalizeRaceForSession(raceInput) {
return {
...raceInput,
storage: buildRaceStorage(raceInput?.storage || {})
};
}
async function buildRaceAvailability(race) {
return resolveRacePklAvailability({ pklRoot: config.pklRoot, race });
}
function issueHandoffToken({ raceId, raceSlug, raceName, raceStorage, lang, returnUrl }) {
const race = mockCatalog[raceId] || {
id: raceId,
slug: raceSlug || `race-${raceId}`,
name: raceName || raceSlug || `Race ${raceId}`,
storage: buildRaceStorage(raceStorage || {})
};
return signPayload({
type: 'handoff',
@ -108,7 +125,8 @@ function issueHandoffToken({ raceId, raceSlug, lang, returnUrl }) {
race: {
id: race.id,
slug: race.slug,
name: race.name
name: race.name,
storage: buildRaceStorage(raceStorage || race.storage || {})
},
lang: lang || 'it',
returnUrl,
@ -231,9 +249,21 @@ app.get('/dev/legacy/race', (req, res) => {
app.get('/dev/legacy/launch', (req, res) => {
const raceId = String(req.query.raceId || '101');
const raceSlug = String(req.query.raceSlug || mockCatalog[raceId]?.slug || `race-${raceId}`);
const raceName = String(req.query.raceName || mockCatalog[raceId]?.name || raceSlug);
const lang = String(req.query.lang || 'it');
const returnUrl = String(req.query.returnUrl || `${config.publicBaseUrl}/dev/legacy/race?raceId=${encodeURIComponent(raceId)}&lang=${encodeURIComponent(lang)}`);
const token = issueHandoffToken({ raceId, raceSlug, lang, returnUrl });
const token = issueHandoffToken({
raceId,
raceSlug,
raceName,
raceStorage: {
year: String(req.query.raceYear || mockCatalog[raceId]?.storage?.year || ''),
monthFolder: String(req.query.raceMonthFolder || mockCatalog[raceId]?.storage?.monthFolder || ''),
raceFolder: String(req.query.raceFolder || mockCatalog[raceId]?.storage?.raceFolder || '')
},
lang,
returnUrl
});
res.redirect(`${config.frontendUrl}/auth/callback?token=${encodeURIComponent(token)}`);
});
@ -261,7 +291,7 @@ app.get('/dev/legacy/return', async (req, res) => {
}
});
app.post('/api/auth/exchange', (req, res) => {
app.post('/api/auth/exchange', async (req, res) => {
try {
const { token } = req.body;
const payload = verifySignedPayload(token, config.sharedSecret);
@ -269,13 +299,18 @@ app.post('/api/auth/exchange', (req, res) => {
throw new Error('Wrong token type');
}
const race = normalizeRaceForSession(payload.race);
const availability = await buildRaceAvailability(race);
const faceAiAllowed = payload.user.membershipStatus === 'active' && availability.available;
const sessionId = createSession({
user: payload.user,
race: payload.race,
race,
lang: payload.lang,
returnUrl: payload.returnUrl,
availability,
access: {
faceAiAllowed: payload.user.membershipStatus === 'active'
faceAiAllowed
}
});
@ -288,11 +323,12 @@ app.post('/api/auth/exchange', (req, res) => {
res.json({
user: payload.user,
race: payload.race,
race,
lang: payload.lang,
returnUrl: payload.returnUrl,
availability,
access: {
faceAiAllowed: true
faceAiAllowed
}
});
} catch (error) {
@ -308,6 +344,19 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
try {
const raceId = String(req.body.raceId || req.faceaiSession.race.id);
const userId = String(req.faceaiSession.user.id);
const race = normalizeRaceForSession(raceId === req.faceaiSession.race.id
? req.faceaiSession.race
: (mockCatalog[raceId] || req.faceaiSession.race));
const availability = await buildRaceAvailability(race);
if (!availability.available) {
res.status(409).json({
error: availability.message,
code: availability.reasonCode || 'RACE_PKL_UNAVAILABLE'
});
return;
}
const activeSearchId = await getActiveSearchId(redis, userId);
if (activeSearchId) {
@ -327,10 +376,10 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
return;
}
const race = mockCatalog[raceId] || req.faceaiSession.race;
const search = await createSearchRecord(redis, {
raceId,
raceName: race?.name || raceId,
raceStorage: race?.storage || availability.storage,
userId,
returnUrl: req.faceaiSession.returnUrl,
lang: req.faceaiSession.lang,
@ -371,6 +420,7 @@ app.post('/api/searches', requireSession, enforceSearchRateLimit, upload.single(
id: updatedSearch.id,
status: updatedSearch.status,
raceId: updatedSearch.raceId,
raceStorage: updatedSearch.raceStorage,
selfieName: updatedSearch.selfieName,
matchCount: updatedSearch.matchCount,
errorCode: updatedSearch.errorCode,

View file

@ -5,6 +5,11 @@ export const mockCatalog = {
id: '101',
slug: 'mezza-di-firenze',
name: 'Mezza di Firenze',
storage: {
year: '2026',
monthFolder: '04.APRILE',
raceFolder: 'PISA'
},
photos: [
{ id: 'f101-001', label: 'Arrivo 001', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-001.jpg' },
{ id: 'f101-002', label: 'Arrivo 002', bib: '245', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-002.jpg' },
@ -22,14 +27,33 @@ export const mockCatalog = {
},
'202': {
id: '202',
slug: 'trail-del-chianti',
name: 'Trail del Chianti',
slug: 'mezza-di-pisa',
name: 'Mezza di Pisa',
storage: {
year: '2026',
monthFolder: '04.APRILE',
raceFolder: 'PISA'
},
photos: [
{ id: 'f202-001', label: 'Bosco 001', bib: '77', checkpoint: 'Bosco', thumb: 'thumb-bosco-001.jpg' },
{ id: 'f202-002', label: 'Salita 002', bib: '77', checkpoint: 'Salita', thumb: 'thumb-salita-002.jpg' },
{ id: 'f202-003', label: 'Arrivo 003', bib: '77', checkpoint: 'Arrivo', thumb: 'thumb-arrivo-003.jpg' },
{ id: 'f202-004', label: 'Bosco 004', bib: '19', checkpoint: 'Bosco', thumb: 'thumb-bosco-004.jpg' }
]
},
'303': {
id: '303',
slug: 'corsa-di-lucca',
name: 'Corsa di Lucca',
storage: {
year: '2026',
monthFolder: '04.APRILE',
raceFolder: 'LUCCA'
},
photos: [
{ id: 'f303-001', label: 'Mura 001', bib: '33', checkpoint: 'Mura', thumb: 'thumb-mura-001.jpg' },
{ id: 'f303-002', label: 'Centro 002', bib: '33', checkpoint: 'Centro', thumb: 'thumb-centro-002.jpg' }
]
}
};

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="row mt-4">
<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>
<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>
<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>
</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>

View file

@ -5,7 +5,6 @@ export const config = {
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
fallbackPklRoot: process.env.FACEAI_TEST_PKL_ROOT || '/data/pkl/test',
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),
resultTtlSeconds: Number(process.env.FACEAI_RESULT_TTL_SECONDS || 24 * 60 * 60)

View file

@ -1,34 +1,22 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { resolveRacePklAvailability } from '../../backend/src/race-storage.js';
async function fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
export async function resolvePklPath({ raceId, raceStorage, pklRoot }) {
const availability = await resolveRacePklAvailability({
pklRoot,
race: {
id: raceId,
storage: raceStorage
}
}
});
export async function resolvePklPath({ raceId, pklRoot, fallbackPklRoot }) {
const preferred = path.join(pklRoot, String(raceId), 'face_encodings.pkl');
if (await fileExists(preferred)) {
return preferred;
if (!availability.available || !availability.pklPath) {
throw new Error(availability.message || `No PKL file available for race ${raceId}`);
}
const flatFile = path.join(pklRoot, `${raceId}.pkl`);
if (await fileExists(flatFile)) {
return flatFile;
}
const fallbackEntries = await fs.readdir(fallbackPklRoot).catch(() => []);
const fallbackFile = fallbackEntries.find((entry) => entry.toLowerCase().endsWith('.pkl'));
if (fallbackFile) {
return path.join(fallbackPklRoot, fallbackFile);
}
throw new Error(`No PKL file available for race ${raceId}`);
return availability.pklPath;
}
export async function runFaceMatcher({ matcherBinary, selfiePath, pklPath, csvPath, logPath, timeoutMs }) {

View file

@ -30,8 +30,8 @@ async function processJob(job) {
try {
const pklPath = await resolvePklPath({
raceId: search.raceId,
pklRoot: config.pklRoot,
fallbackPklRoot: config.fallbackPklRoot
raceStorage: search.raceStorage,
pklRoot: config.pklRoot
});
const csvPath = path.join(searchDir, 'result.csv');

View file

@ -9,6 +9,7 @@ services:
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_PUBLIC_BASE_URL: http://localhost:3001
FACEAI_LEGACY_RETURN_URL: http://localhost:8080/faceai_return.php
FACEAI_PKL_ROOT: /data/pkl
FACEAI_ENABLE_LOCAL_LEGACY_STATIC: 1
FACEAI_LOCAL_LEGACY_STATIC_ROOT: /legacy-www
FACEAI_SHARED_SECRET: change-me
@ -19,6 +20,7 @@ services:
volumes:
- .:/app
- ../www:/legacy-www:ro
- ../test_pkl:/data/pkl:ro
- faceai-runtime:/data/runtime
ports:
- "3001:3001"
@ -35,13 +37,12 @@ services:
FACEAI_QUEUE_NAME: faceai-searches
FACEAI_RUNTIME_ROOT: /data/runtime
FACEAI_PKL_ROOT: /data/pkl
FACEAI_TEST_PKL_ROOT: /data/pkl/test
FACEAI_WORKER_CONCURRENCY: 2
FACEAI_MATCHER_BINARY: /opt/face-recognition/face_matcher
volumes:
- .:/app
- ../bin/Face_Recognition_Unix:/opt/face-recognition:ro
- ../test_pkl:/data/pkl/test:ro
- ../test_pkl:/data/pkl:ro
- faceai-runtime:/data/runtime
depends_on:
- redis
@ -55,6 +56,7 @@ services:
image: php:8.3-apache
container_name: regalami-legacy-php
environment:
FACEAI_FEATURE_ENABLED: 1
FACEAI_BACKEND_INTERNAL_URL: http://faceai:3001
FACEAI_FRONTEND_URL: http://localhost:3001
FACEAI_SHARED_SECRET: change-me

View file

@ -10,7 +10,7 @@ Add an internal processor service that executes `face_matcher` jobs for the publ
- add a dedicated `processor` workspace and container scaffold
- replace in-memory search orchestration in the public backend
- preserve the existing frontend polling and legacy return flow
- support local PKL testing from `test_pkl/`
- support local PKL testing from `test_pkl/` mounted with the same directory shape used in hosted deployment
This slice does not yet implement production NAS mounting, persistent databases, or a final parser tailored to the real matcher CSV format.
@ -53,25 +53,34 @@ The lock is released only when the processor marks the search as terminal: `comp
## Race And PKL Resolution
The canonical race key is the legacy `id_gara`, already exposed as `raceId` in the existing handoff flow.
The canonical race key is still the legacy `id_gara`, but the worker no longer guesses the PKL path from `raceId` alone.
The processor resolves the PKL path using a race-based directory layout:
The legacy handoff must provide a `raceStorage` object with:
- `year`
- `monthFolder` like `04.APRILE`
- `raceFolder` like `PISA`
The processor resolves the PKL path using this mounted directory layout:
```text
/data/pkl/
101/
face_encodings.pkl
202/
face_encodings.pkl
2026/
04.APRILE/
PISA/
face_encodings_20260330_170210.pkl
LUCCA/
face_encodings_20260330_170155.pkl
```
The lookup rule is:
1. try `/data/pkl/{raceId}/face_encodings.pkl`
2. optionally fall back to `/data/pkl/{raceId}.pkl`
3. fail the job if neither exists
1. resolve `/data/pkl/{year}/{monthFolder}/{raceFolder}`
2. list files at that race root
3. take the first `.pkl` file found there, regardless of filename
4. fail the job if the directory does not exist or contains no `.pkl` file
For local development, `test_pkl/` is mounted into `/data/pkl/test` and the backend can fall back to the first `.pkl` file in that folder when no race-specific file exists yet.
For local development, `test_pkl/` is mounted directly into `/data/pkl` in both the public FaceAI container and the processor container, so the same rule is used in every environment.
## Shared Runtime Storage
@ -91,14 +100,15 @@ Both the public backend and the processor mount the same writable runtime direct
1. frontend uploads a selfie and calls `POST /api/searches`
2. backend validates session, rate limit, and active-user lock
3. backend stores the upload and creates a Redis search record with status `queued`
4. backend enqueues a BullMQ job
5. processor picks up the job and sets status `processing`
6. processor runs `face_matcher`
7. processor parses CSV output into matches
8. processor stores a result record and marks the search `completed`
9. frontend polling reads Redis-backed state through `GET /api/searches/:id`
10. existing redirect flow sends the user back to the legacy filtered page
3. backend verifies that the mounted race directory exists and already contains a `.pkl`; if not, it rejects the request before queueing
4. backend stores the upload and creates a Redis search record with status `queued`
5. backend enqueues a BullMQ job
6. processor picks up the job and sets status `processing`
7. processor runs `face_matcher`
8. processor parses CSV output into matches
9. processor stores a result record and marks the search `completed`
10. frontend polling reads Redis-backed state through `GET /api/searches/:id`
11. existing redirect flow sends the user back to the legacy filtered page
## Search Record Shape
@ -107,6 +117,11 @@ Both the public backend and the processor mount the same writable runtime direct
"id": "search_...",
"status": "queued",
"raceId": "101",
"raceStorage": {
"year": "2026",
"monthFolder": "04.APRILE",
"raceFolder": "PISA"
},
"userId": "legacy-user-1",
"returnUrl": "https://...",
"lang": "it",
@ -162,5 +177,4 @@ Both the public backend and the processor mount the same writable runtime direct
- confirm the real CSV columns emitted by `face_matcher`
- verify the Linux binary shared library requirements inside the processor image
- replace the PKL fallback with a strict NAS-backed race mapping once the final folder layout is agreed
- add cleanup jobs for expired runtime files

View file

@ -114,6 +114,19 @@ function getCurrentLangValue() {
return $("html").attr("lang") || "it";
}
function getFaceAiStorageValue(fieldId, simulatorKey) {
var field = $("#" + fieldId);
if (field.length && field.val()) {
return field.val();
}
if (window.faceAiSimulator && window.faceAiSimulator.raceStorage && window.faceAiSimulator.raceStorage[simulatorKey]) {
return window.faceAiSimulator.raceStorage[simulatorKey];
}
return "";
}
function faceAiFeatureEnabled() {
var config = window.faceAiConfig || {};
var simulatorConfig = window.faceAiSimulator || {};
@ -136,6 +149,57 @@ function faceAiEscapeHtml(value) {
.replace(/'/g, "&#39;");
}
function isFaceAiDebugEnabled() {
var hostname = window.location && window.location.hostname ? window.location.hostname : "";
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function getFaceAiDebugPayload() {
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
var racePathBase = $("#faceAiRacePathBase").val() || "";
var raceStorageRelativeDir = $("#faceAiRaceStorageRelativeDir").val() || [raceYear, raceMonthFolder, raceFolder].filter(Boolean).join("/");
var simulatorConfig = window.faceAiSimulator || null;
return {
pageUrl: window.location.href,
race: {
id: $("#id_gara").val() || "",
slug: $("#garaDesc").val() || "",
name: $("h1.my-4").last().text().replace(/\s+/g, " ").trim(),
lang: getCurrentLangValue(),
storage: {
year: raceYear,
monthFolder: raceMonthFolder,
raceFolder: raceFolder,
pathBase: racePathBase,
relativeDir: raceStorageRelativeDir
}
},
simulator: simulatorConfig,
handoff: {
url: (simulatorConfig && simulatorConfig.handoffUrl) || "faceai_handoff.php",
returnUrl: (simulatorConfig && simulatorConfig.returnUrl) || window.location.href
}
};
}
function logFaceAiDebug(label, extraPayload) {
if (!isFaceAiDebugEnabled() || !window.console || typeof window.console.groupCollapsed !== "function") {
return;
}
var payload = getFaceAiDebugPayload();
if (extraPayload) {
payload.extra = extraPayload;
}
window.console.groupCollapsed("[FaceAI] " + label);
window.console.log(payload);
window.console.groupEnd();
}
function getFaceAiErrorState() {
if (typeof URLSearchParams === "undefined") {
return null;
@ -191,6 +255,9 @@ function buildFaceAiLaunchUrl() {
var raceId = $("#id_gara").val() || "";
var raceSlug = $("#garaDesc").val() || "";
var raceName = $("h1.my-4").last().text().replace(/\s+/g, " ").trim();
var raceYear = getFaceAiStorageValue("faceAiRaceYear", "year");
var raceMonthFolder = getFaceAiStorageValue("faceAiRaceMonthFolder", "monthFolder");
var raceFolder = getFaceAiStorageValue("faceAiRaceFolder", "raceFolder");
var lang = getCurrentLangValue();
var handoffUrl = (window.faceAiSimulator && window.faceAiSimulator.handoffUrl) || "faceai_handoff.php";
var returnUrl = (window.faceAiSimulator && window.faceAiSimulator.returnUrl) || window.location.href;
@ -198,6 +265,9 @@ function buildFaceAiLaunchUrl() {
"raceId=" + encodeURIComponent(raceId),
"raceSlug=" + encodeURIComponent(raceSlug),
"raceName=" + encodeURIComponent(raceName),
"raceYear=" + encodeURIComponent(raceYear),
"raceMonthFolder=" + encodeURIComponent(raceMonthFolder),
"raceFolder=" + encodeURIComponent(raceFolder),
"lang=" + encodeURIComponent(lang),
"returnUrl=" + encodeURIComponent(returnUrl)
];
@ -215,10 +285,18 @@ function buildFaceAiLaunchUrl() {
query.push("devMembershipStatus=" + encodeURIComponent(window.faceAiSimulator.devMembershipStatus));
}
logFaceAiDebug("Legacy launch payload prepared", {
query: query.slice(0),
raceId: raceId,
raceSlug: raceSlug,
raceName: raceName
});
return handoffUrl + "?" + query.join("&");
}
function launchFaceAi() {
logFaceAiDebug("Redirecting to FaceAI handoff");
$("body").addClass("loading");
window.location.href = buildFaceAiLaunchUrl();
return false;
@ -451,6 +529,7 @@ function goPage()
$(function() {
initFaceAiRaceSearchButton();
initFaceAiErrorModal();
logFaceAiDebug("Legacy race page ready");
});

View file

@ -8,6 +8,9 @@ try {
$raceId = faceai_request_value('raceId');
$raceSlug = faceai_request_value('raceSlug');
$raceName = faceai_request_value('raceName', $raceSlug !== '' ? $raceSlug : $raceId);
$raceYear = faceai_request_value('raceYear');
$raceMonthFolder = faceai_request_value('raceMonthFolder');
$raceFolder = faceai_request_value('raceFolder');
$lang = faceai_request_value('lang', 'it');
$returnUrl = faceai_request_value('returnUrl');
@ -36,6 +39,20 @@ try {
faceai_redirect_with_error($returnUrl, 'Il tuo account non e abilitato all uso di Face ID.');
}
$racePayload = array(
'id' => $raceId,
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
'name' => $raceName !== '' ? $raceName : $raceId
);
if ($raceYear !== '' && $raceMonthFolder !== '' && $raceFolder !== '') {
$racePayload['storage'] = array(
'year' => $raceYear,
'monthFolder' => $raceMonthFolder,
'raceFolder' => strtoupper(trim($raceFolder))
);
}
$payload = array(
'type' => 'handoff',
'user' => array(
@ -44,11 +61,7 @@ try {
'email' => $identity['email'],
'membershipStatus' => $identity['membershipStatus']
),
'race' => array(
'id' => $raceId,
'slug' => $raceSlug !== '' ? $raceSlug : $raceId,
'name' => $raceName !== '' ? $raceName : $raceId
),
'race' => $racePayload,
'lang' => $lang,
'returnUrl' => $returnUrl,
'expiresAt' => ((int) round(microtime(true) * 1000)) + (5 * 60 * 1000)

View file

@ -1,11 +1,14 @@
<?php
require_once __DIR__ . '/faceai_simulator_view.php';
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '101';
$raceId = isset($_GET['raceId']) ? trim((string) $_GET['raceId']) : '202';
$lang = isset($_GET['lang']) ? trim((string) $_GET['lang']) : 'it';
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-firenze';
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Firenze';
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName);
$raceSlug = isset($_GET['raceSlug']) ? trim((string) $_GET['raceSlug']) : 'mezza-di-pisa';
$raceName = isset($_GET['raceName']) ? trim((string) $_GET['raceName']) : 'Mezza di Pisa';
$raceYear = isset($_GET['raceYear']) ? trim((string) $_GET['raceYear']) : '2026';
$raceMonthFolder = isset($_GET['raceMonthFolder']) ? trim((string) $_GET['raceMonthFolder']) : '04.APRILE';
$raceFolder = isset($_GET['raceFolder']) ? trim((string) $_GET['raceFolder']) : 'PISA';
$returnUrl = 'http://localhost:8080/faceai_simulator.php?raceId=' . rawurlencode($raceId) . '&lang=' . rawurlencode($lang) . '&raceSlug=' . rawurlencode($raceSlug) . '&raceName=' . rawurlencode($raceName) . '&raceYear=' . rawurlencode($raceYear) . '&raceMonthFolder=' . rawurlencode($raceMonthFolder) . '&raceFolder=' . rawurlencode($raceFolder);
$photos = array(
array('id' => 'f101-001', 'thumb' => 'thumb-arrivo-001.jpg', 'label' => 'Arrivo 001', 'checkpoint' => 'Arrivo'),
@ -27,6 +30,9 @@ faceai_sim_render_page(array(
'lang' => $lang,
'raceSlug' => $raceSlug,
'raceName' => $raceName,
'raceYear' => $raceYear,
'raceMonthFolder' => $raceMonthFolder,
'raceFolder' => $raceFolder,
'returnUrl' => $returnUrl,
'banner' => 'Questa pagina PHP simula il punto di ingresso del sito legacy. Il vecchio select con ID <strong>tipoPuntoFoto</strong> viene rimosso dal JavaScript originale e sostituito dal pulsante Face ID.',
'totalLabel' => count($photos) . ' foto demo',

View file

@ -11,6 +11,9 @@ function faceai_sim_render_page(array $options)
$lang = $options['lang'];
$raceSlug = $options['raceSlug'];
$raceName = $options['raceName'];
$raceYear = $options['raceYear'] ?? '';
$raceMonthFolder = $options['raceMonthFolder'] ?? '';
$raceFolder = $options['raceFolder'] ?? '';
$returnUrl = $options['returnUrl'];
$banner = $options['banner'];
$totalLabel = $options['totalLabel'];
@ -69,8 +72,8 @@ function faceai_sim_render_page(array $options)
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-white fixed-top">
<div class="container">
<a class="navbar-brand" href="faceai_simulator.php?raceId=<?php echo faceai_sim_html($raceId); ?>&lang=<?php echo faceai_sim_html($lang); ?>"><img src="images/layout/regalami-un-sorriso-ets-640.png" alt="Regalami Un Sorriso Ets" width="100"></a>
<button class="navbar-toggler navbar-toggler-right" type="button"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse show" id="navbarResponsive">
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="index.jsp">Home</a></li>
<li class="nav-item"><a class="nav-link" href="associazione.jsp">Associazione</a></li>
@ -105,6 +108,9 @@ function faceai_sim_render_page(array $options)
<input name="id_gara" id="id_gara" type="hidden" value="<?php echo faceai_sim_html($raceId); ?>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<?php echo faceai_sim_html($raceSlug); ?>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<?php echo faceai_sim_html($raceYear); ?>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<?php echo faceai_sim_html($raceMonthFolder); ?>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<?php echo faceai_sim_html($raceFolder); ?>">
<input name="lang" id="lang" type="hidden" value="<?php echo faceai_sim_html($lang); ?>">
<input name="pageNumber" id="pageNumber" type="hidden" value="1">
<input name="actionPage" id="actionPage" type="hidden" value="Foto.abl">
@ -169,11 +175,54 @@ window.faceAiSimulator = {
enabled: true,
handoffUrl: 'faceai_handoff.php',
returnUrl: <?php echo json_encode($returnUrl); ?>,
raceStorage: {
year: <?php echo json_encode($raceYear); ?>,
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
raceFolder: <?php echo json_encode($raceFolder); ?>
},
devUserId: '1',
devDisplayName: 'Mario Rossi',
devEmail: 'mario.rossi@example.test',
devMembershipStatus: 'active'
};
(function () {
var hostname = window.location && window.location.hostname ? window.location.hostname : '';
var isLocalDebug = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
if (!isLocalDebug || !window.console || typeof window.console.groupCollapsed !== 'function') {
return;
}
window.console.groupCollapsed('[FaceAI] Simulator bootstrap');
window.console.log({
pageUrl: window.location.href,
race: {
id: <?php echo json_encode($raceId); ?>,
slug: <?php echo json_encode($raceSlug); ?>,
name: <?php echo json_encode($raceName); ?>,
lang: <?php echo json_encode($lang); ?>,
storage: {
year: <?php echo json_encode($raceYear); ?>,
monthFolder: <?php echo json_encode($raceMonthFolder); ?>,
raceFolder: <?php echo json_encode($raceFolder); ?>,
relativeDir: <?php echo json_encode(implode('/', array_values(array_filter([$raceYear, $raceMonthFolder, $raceFolder], static function ($segment) {
return $segment !== null && $segment !== '';
})))); ?>
}
},
handoff: {
url: 'faceai_handoff.php',
returnUrl: <?php echo json_encode($returnUrl); ?>
},
devUser: {
id: '1',
displayName: 'Mario Rossi',
email: 'mario.rossi@example.test',
membershipStatus: 'active'
}
});
window.console.groupEnd();
}());
</script>
<?php endif; ?>
<script src="vendor/jquery/jquery.min.js"></script>

View file

@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
}
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
String faceAiRaceYear = "";
String faceAiRaceMonthFolder = "";
String faceAiRaceFolder = "";
String faceAiRaceStorageRelativeDir = "";
if (!faceAiRacePathBase.isEmpty()) {
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
if (!faceAiSegment.isEmpty()) {
faceAiNormalizedSegments.add(faceAiSegment);
}
}
if (faceAiNormalizedSegments.size() > 0) {
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
}
if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
}
if (faceAiNormalizedSegments.size() > 2) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
} else if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
}
}
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
faceAiCalendar.setTime(faceAiRaceDate);
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
}
if (faceAiRaceFolder.isEmpty()) {
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
}
if (!faceAiRaceYear.isEmpty()) {
faceAiRaceStorageRelativeDir = faceAiRaceYear;
if (!faceAiRaceMonthFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
}
if (!faceAiRaceFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
}
}
%>
<!-- InstanceEndEditable -->
<!-- InstanceBeginEditable name="doctitle" -->
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
<div class="row">
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">

View file

@ -56,6 +56,50 @@ if (faceAiFeatureEnabledValue == null) {
faceAiFeatureEnabledValue = System.getProperty("FACEAI_FEATURE_ENABLED", "0");
}
boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".equalsIgnoreCase(faceAiFeatureEnabledValue) || "yes".equalsIgnoreCase(faceAiFeatureEnabledValue) || "on".equalsIgnoreCase(faceAiFeatureEnabledValue);
java.util.Date faceAiRaceDate = CR.getGara().getDataGaraInizio();
String faceAiRacePathBase = CR.getGara().getPathBase() != null ? CR.getGara().getPathBase().trim() : "";
String faceAiRaceYear = "";
String faceAiRaceMonthFolder = "";
String faceAiRaceFolder = "";
String faceAiRaceStorageRelativeDir = "";
if (!faceAiRacePathBase.isEmpty()) {
String[] faceAiPathSegments = faceAiRacePathBase.split("/");
java.util.ArrayList faceAiNormalizedSegments = new java.util.ArrayList();
for (int faceAiSegmentIndex = 0; faceAiSegmentIndex < faceAiPathSegments.length; faceAiSegmentIndex++) {
String faceAiSegment = faceAiPathSegments[faceAiSegmentIndex] != null ? faceAiPathSegments[faceAiSegmentIndex].trim() : "";
if (!faceAiSegment.isEmpty()) {
faceAiNormalizedSegments.add(faceAiSegment);
}
}
if (faceAiNormalizedSegments.size() > 0) {
faceAiRaceYear = (String) faceAiNormalizedSegments.get(0);
}
if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceMonthFolder = (String) faceAiNormalizedSegments.get(1);
}
if (faceAiNormalizedSegments.size() > 2) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(2);
} else if (faceAiNormalizedSegments.size() > 1) {
faceAiRaceFolder = (String) faceAiNormalizedSegments.get(faceAiNormalizedSegments.size() - 1);
}
}
if (faceAiRaceYear.isEmpty() && faceAiRaceDate != null) {
java.util.Calendar faceAiCalendar = java.util.Calendar.getInstance();
faceAiCalendar.setTime(faceAiRaceDate);
faceAiRaceYear = String.valueOf(faceAiCalendar.get(java.util.Calendar.YEAR));
}
if (faceAiRaceFolder.isEmpty()) {
faceAiRaceFolder = String.valueOf(CR.getGara().getId_gara());
}
if (!faceAiRaceYear.isEmpty()) {
faceAiRaceStorageRelativeDir = faceAiRaceYear;
if (!faceAiRaceMonthFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceMonthFolder;
}
if (!faceAiRaceFolder.isEmpty()) {
faceAiRaceStorageRelativeDir += "/" + faceAiRaceFolder;
}
}
%>
<!-- InstanceEndEditable -->
<!-- InstanceBeginEditable name="doctitle" -->
@ -121,6 +165,11 @@ boolean faceAiFeatureEnabled = "1".equals(faceAiFeatureEnabledValue) || "true".e
<input name="id_gara" id="id_gara" type="hidden" value="<%= CR.getId_gara() %>">
<input name="id_foto" id="id_foto" type="hidden">
<input name="garaDesc" id="garaDesc" type="hidden" value="<%=bean.getDescrizioneGaraHtml() %>">
<input name="faceAiRaceYear" id="faceAiRaceYear" type="hidden" value="<%= faceAiRaceYear %>">
<input name="faceAiRaceMonthFolder" id="faceAiRaceMonthFolder" type="hidden" value="<%= faceAiRaceMonthFolder %>">
<input name="faceAiRaceFolder" id="faceAiRaceFolder" type="hidden" value="<%= faceAiRaceFolder %>">
<input name="faceAiRacePathBase" id="faceAiRacePathBase" type="hidden" value="<%= faceAiRacePathBase %>">
<input name="faceAiRaceStorageRelativeDir" id="faceAiRaceStorageRelativeDir" type="hidden" value="<%= faceAiRaceStorageRelativeDir %>">
<div class="row">
<input type="hidden" name="pageNumber" id="pageNumber" value="<%=list.getPageNumber()%>">
<input type="hidden" name="actionPage" id="actionPage" value="Foto.abl">