Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
af8647f3aa
commit
c67bb02173
27 changed files with 1738 additions and 324 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
130
faceai/apps/backend/src/race-storage.js
Normal file
130
faceai/apps/backend/src/race-storage.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
56
faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue
Normal file
56
faceai/apps/frontend/src/components/FaceAiFeedbackPanel.vue
Normal 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>
|
||||
110
faceai/apps/frontend/src/components/FaceAiHeroCard.vue
Normal file
110
faceai/apps/frontend/src/components/FaceAiHeroCard.vue
Normal 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>
|
||||
394
faceai/apps/frontend/src/components/FaceAiUploadPanel.vue
Normal file
394
faceai/apps/frontend/src/components/FaceAiUploadPanel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
478
faceai/apps/frontend/src/composables/useFaceAiHome.js
Normal file
478
faceai/apps/frontend/src/composables/useFaceAiHome.js
Normal 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 l’immagine per caricarla',
|
||||
uploaderBrowse: 'Scegli immagine',
|
||||
uploaderFormats: 'Formati supportati: JPG, PNG, WEBP',
|
||||
uploaderSelected: 'File selezionato',
|
||||
uploaderReplace: 'Sostituisci',
|
||||
uploaderRemove: 'Rimuovi',
|
||||
backButton: 'Torna alla pagina gara',
|
||||
uploadButton: 'Avvia ricerca Face ID',
|
||||
openSimulator: 'Apri il simulatore legacy',
|
||||
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
|
||||
sessionLoading: 'Caricamento della sessione FaceAI…',
|
||||
submitLoading: 'Invio del selfie e preparazione della ricerca…',
|
||||
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
|
||||
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
|
||||
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
|
||||
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
|
||||
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
|
||||
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
|
||||
matchesLabel: 'Foto trovate',
|
||||
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
|
||||
noFileCta: 'Seleziona un’immagine per sbloccare la ricerca.',
|
||||
invalidImage: 'Seleziona un file immagine valido.',
|
||||
pollError: 'Impossibile leggere lo stato della ricerca.',
|
||||
searchFailed: 'La ricerca non è andata a buon fine.',
|
||||
redirectError: 'Impossibile generare il link di ritorno.',
|
||||
chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.',
|
||||
raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.',
|
||||
searchCreateError: 'Impossibile avviare la ricerca.',
|
||||
faceAiAlt: 'FaceAI',
|
||||
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Face ID',
|
||||
pageHeadline: 'Find your photos with a selfie',
|
||||
pageIntro: 'Upload a recent picture of yourself and Face ID will search only within the current race.',
|
||||
userFallback: 'FaceAI session',
|
||||
raceFallback: 'Current race',
|
||||
statusReady: 'Ready',
|
||||
statusProcessing: 'Processing',
|
||||
statusCompleted: 'Completed',
|
||||
statusFailed: 'Error',
|
||||
statusLabel: 'Status',
|
||||
userLabel: 'User',
|
||||
raceLabel: 'Race',
|
||||
languageLabel: 'Language',
|
||||
uploaderTitle: 'Upload your selfie',
|
||||
uploaderHint: 'Drag an image here or choose it from your device.',
|
||||
uploaderDragIdle: 'Drag your selfie here',
|
||||
uploaderDragActive: 'Drop the image to upload it',
|
||||
uploaderBrowse: 'Choose image',
|
||||
uploaderFormats: 'Supported formats: JPG, PNG, WEBP',
|
||||
uploaderSelected: 'Selected file',
|
||||
uploaderReplace: 'Replace',
|
||||
uploaderRemove: 'Remove',
|
||||
backButton: 'Back to the race page',
|
||||
uploadButton: 'Start Face ID search',
|
||||
openSimulator: 'Open the legacy simulator',
|
||||
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
|
||||
sessionLoading: 'Loading the FaceAI session…',
|
||||
submitLoading: 'Uploading the selfie and preparing the search…',
|
||||
redirectLoading: 'Redirecting to the filtered legacy page…',
|
||||
processingLoading: 'Biometric search in progress across all race photos…',
|
||||
unavailableDefault: 'FaceAI is not available for this race.',
|
||||
readyMessage: 'Select a selfie to start a search limited to the current race.',
|
||||
completedMessage: 'Search completed. Found {count} matching photos.',
|
||||
failedMessage: 'The search did not complete. Check the message and try again.',
|
||||
matchesLabel: 'Photos found',
|
||||
redirectMessage: 'Redirecting to the filtered legacy page…',
|
||||
noFileCta: 'Select an image to unlock the search action.',
|
||||
invalidImage: 'Select a valid image file.',
|
||||
pollError: 'Unable to read the search status.',
|
||||
searchFailed: 'The search failed.',
|
||||
redirectError: 'Unable to build the return link.',
|
||||
chooseSelfie: 'Choose a selfie before starting the search.',
|
||||
raceDataUnavailable: 'FaceAI data is not available for this race.',
|
||||
searchCreateError: 'Unable to start the search.',
|
||||
faceAiAlt: 'FaceAI',
|
||||
dropzoneDisabled: 'Upload is not available for this race.'
|
||||
}
|
||||
};
|
||||
|
||||
const knownServerMessages = {
|
||||
'No training dataset available for this race.': 'raceDataUnavailable',
|
||||
'FaceAI data is not available for this race.': 'raceDataUnavailable',
|
||||
'FaceAI is not available for this race.': 'unavailableDefault',
|
||||
'Unable to read search status.': 'pollError',
|
||||
'The search failed.': 'searchFailed',
|
||||
'Unable to build return URL.': 'redirectError',
|
||||
'Unable to create the search.': 'searchCreateError',
|
||||
'Choose a selfie before starting the search.': 'chooseSelfie'
|
||||
};
|
||||
|
||||
const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it';
|
||||
|
||||
export function useFaceAiHome() {
|
||||
const session = ref(null);
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref('');
|
||||
const selectedFile = ref(null);
|
||||
const activeSearch = ref(null);
|
||||
const redirectUrl = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isRedirecting = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const fileInput = ref(null);
|
||||
let pollTimer = null;
|
||||
let dragDepth = 0;
|
||||
|
||||
const currentLocale = computed(() => {
|
||||
const language = (session.value?.lang || document.documentElement.lang || 'it').toLowerCase();
|
||||
return language.startsWith('en') ? 'en' : 'it';
|
||||
});
|
||||
|
||||
function t(key, params = {}) {
|
||||
const message = copy[currentLocale.value][key] || copy.it[key] || key;
|
||||
return Object.keys(params).reduce((text, paramKey) => text.replace(`{${paramKey}}`, String(params[paramKey])), message);
|
||||
}
|
||||
|
||||
function localizeServerMessage(message, fallbackKey) {
|
||||
if (!message) {
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
if (currentLocale.value === 'en') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const mappedKey = knownServerMessages[message];
|
||||
if (mappedKey) {
|
||||
return t(mappedKey);
|
||||
}
|
||||
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
function shouldLogFaceAiDebug() {
|
||||
return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
}
|
||||
|
||||
function logFaceAiDebug(label, extra = null) {
|
||||
if (!shouldLogFaceAiDebug()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
pageUrl: window.location.href,
|
||||
session: session.value,
|
||||
availability: raceAvailability.value,
|
||||
activeSearch: activeSearch.value,
|
||||
redirectUrl: redirectUrl.value,
|
||||
extra
|
||||
};
|
||||
|
||||
console.groupCollapsed(`[FaceAI] ${label}`);
|
||||
console.log(payload);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
|
||||
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
|
||||
const raceAvailability = computed(() => session.value?.availability || null);
|
||||
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
|
||||
|
||||
const canStartSearch = computed(() => {
|
||||
if (!session.value || !selectedFile.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session.value.access?.faceAiAllowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return raceAvailability.value?.available === true && !isWorking.value;
|
||||
});
|
||||
|
||||
const selectedFileSizeLabel = computed(() => {
|
||||
if (!selectedFile.value?.size) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sizeInMb = selectedFile.value.size / (1024 * 1024);
|
||||
if (sizeInMb >= 1) {
|
||||
return `${sizeInMb.toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return `${Math.max(1, Math.round(selectedFile.value.size / 1024))} KB`;
|
||||
});
|
||||
|
||||
const activeSearchStatusLabel = computed(() => {
|
||||
const status = activeSearch.value?.status;
|
||||
|
||||
if (status === 'processing') {
|
||||
return t('statusProcessing');
|
||||
}
|
||||
if (status === 'completed') {
|
||||
return t('statusCompleted');
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return t('statusFailed');
|
||||
}
|
||||
|
||||
return t('statusReady');
|
||||
});
|
||||
|
||||
const busyLabel = computed(() => {
|
||||
if (loading.value) {
|
||||
return t('sessionLoading');
|
||||
}
|
||||
|
||||
if (isSubmitting.value) {
|
||||
return t('submitLoading');
|
||||
}
|
||||
|
||||
if (isRedirecting.value) {
|
||||
return t('redirectLoading');
|
||||
}
|
||||
|
||||
if (activeSearch.value?.status === 'processing') {
|
||||
return t('processingLoading');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!activeSearch.value) {
|
||||
if (session.value && raceAvailability.value && !raceAvailability.value.available) {
|
||||
return localizeServerMessage(raceAvailability.value.message, 'unavailableDefault');
|
||||
}
|
||||
|
||||
return t('readyMessage');
|
||||
}
|
||||
|
||||
if (activeSearch.value.status === 'completed') {
|
||||
return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 });
|
||||
}
|
||||
|
||||
if (activeSearch.value.status === 'failed') {
|
||||
return localizeServerMessage(activeSearch.value.errorMessage, 'failedMessage');
|
||||
}
|
||||
|
||||
return t('processingLoading');
|
||||
});
|
||||
|
||||
function openFilePicker() {
|
||||
if (!canPickFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function setSelectedFile(file) {
|
||||
if (!file) {
|
||||
selectedFile.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type || !file.type.startsWith('image/')) {
|
||||
selectedFile.value = null;
|
||||
errorMessage.value = t('invalidImage');
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile.value = file;
|
||||
errorMessage.value = '';
|
||||
}
|
||||
|
||||
function clearSelectedFile() {
|
||||
selectedFile.value = null;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(event) {
|
||||
setSelectedFile(event.target.files?.[0] || null);
|
||||
}
|
||||
|
||||
function onDragEnter(event) {
|
||||
if (!canPickFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragOver(event) {
|
||||
if (!canPickFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave(event) {
|
||||
if (!canPickFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (dragDepth === 0) {
|
||||
isDragging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event) {
|
||||
if (!canPickFile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
isDragging.value = false;
|
||||
setSelectedFile(event.dataTransfer?.files?.[0] || null);
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
loading.value = true;
|
||||
const response = await fetch('/api/session', { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
loading.value = false;
|
||||
logFaceAiDebug('Session load failed', { status: response.status });
|
||||
return;
|
||||
}
|
||||
|
||||
session.value = await response.json();
|
||||
loading.value = false;
|
||||
logFaceAiDebug('Session loaded');
|
||||
}
|
||||
|
||||
async function pollSearch(searchId) {
|
||||
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
errorMessage.value = t('pollError');
|
||||
isSubmitting.value = false;
|
||||
logFaceAiDebug('Search polling failed', { searchId, status: response.status });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSearch.value = await response.json();
|
||||
logFaceAiDebug('Search status updated', { searchId, status: activeSearch.value.status });
|
||||
if (activeSearch.value.status === 'failed') {
|
||||
isSubmitting.value = false;
|
||||
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSearch.value.status === 'completed') {
|
||||
isSubmitting.value = false;
|
||||
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
|
||||
const payload = await redirectResponse.json();
|
||||
if (!redirectResponse.ok) {
|
||||
errorMessage.value = localizeServerMessage(payload.error, 'redirectError');
|
||||
logFaceAiDebug('Redirect build failed', { searchId, payload });
|
||||
return;
|
||||
}
|
||||
redirectUrl.value = payload.url;
|
||||
isRedirecting.value = true;
|
||||
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
|
||||
window.setTimeout(() => {
|
||||
window.location.href = payload.url;
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollSearch(searchId);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
errorMessage.value = '';
|
||||
redirectUrl.value = '';
|
||||
isRedirecting.value = false;
|
||||
|
||||
if (!selectedFile.value) {
|
||||
errorMessage.value = t('chooseSelfie');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
|
||||
errorMessage.value = localizeServerMessage(raceAvailability.value?.message, 'raceDataUnavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set('raceId', session.value.race.id);
|
||||
formData.set('selfie', selectedFile.value);
|
||||
|
||||
const response = await fetch('/api/searches', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
errorMessage.value = localizeServerMessage(payload.error, 'searchCreateError');
|
||||
isSubmitting.value = false;
|
||||
logFaceAiDebug('Search creation failed', { status: response.status, payload });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSearch.value = payload;
|
||||
logFaceAiDebug('Search created', { payload });
|
||||
pollSearch(payload.id);
|
||||
}
|
||||
|
||||
onMounted(loadSession);
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) {
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeSearch,
|
||||
activeSearchStatusLabel,
|
||||
busyLabel,
|
||||
canPickFile,
|
||||
canStartSearch,
|
||||
clearSelectedFile,
|
||||
currentLocale,
|
||||
errorMessage,
|
||||
fileInput,
|
||||
isDragging,
|
||||
isProcessingSearch,
|
||||
isRedirecting,
|
||||
isSubmitting,
|
||||
isWorking,
|
||||
loading,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onFileChange,
|
||||
openFilePicker,
|
||||
redirectUrl,
|
||||
selectedFile,
|
||||
selectedFileSizeLabel,
|
||||
session,
|
||||
simulatorUrl,
|
||||
statusLabel,
|
||||
submitSearch,
|
||||
t
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
BIN
test_pkl/2026/04.APRILE/PISA/face_encodings_20260412_171808.pkl
Normal file
BIN
test_pkl/2026/04.APRILE/PISA/face_encodings_20260412_171808.pkl
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -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, "'");
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue