Enhance Face Recognition Workflow with Remote Capabilities and Consent Management
All checks were successful
Publish FaceAI Container / publish (push) Successful in 5m20s

- Updated `run_face_encoder.bat` to include remote execution parameters for SSH and SCP.
- Refactored `run_face_encoder.ps1` to accept remote execution parameters and handle remote file operations.
- Modified `FaceAiUploadPanel.vue` to introduce consent management UI and error handling for race availability.
- Enhanced `useFaceAiHome.js` to manage consent acceptance and integrate cookie handling for biometric data processing notice.
- Updated `HomeView.vue` to streamline the upload panel and integrate consent handling logic.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
MaddoScientisto 2026-04-24 19:33:38 +02:00
commit 6e37aa16c8
6 changed files with 1163 additions and 129 deletions

View file

@ -0,0 +1,449 @@
# WWW Modernization Migration Analysis
## Goal
Define a realistic path to migrate the legacy `www/` surface away from JSP and PHP toward a modern Node + Vue application without attempting a risky rewrite-in-place.
The recommended strategy is incremental:
1. keep the current legacy application running as the system of record
2. introduce a modern Node + Vue web layer beside it
3. migrate page families one slice at a time
4. retire JSP and PHP only after each slice has a proven replacement
## Executive Summary
The `www/` layer is not just a collection of templates. It is a mixed runtime where rendering, session handling, business rules, and request-scoped data assembly are tightly coupled inside JSP pages and shared includes.
Representative examples in this repository:
- `www/fotoCR.jsp` mixes HTML, session beans, request beans, taglibs, and inline Java for race search and FaceAI launch metadata.
- `www/_inc_header.jsp` holds shared navigation, login-dependent UI, language switching, and archive links.
- `www/gallery1.jsp` and `www/gallery2.jsp` are JSP wrappers around older PHP gallery implementations.
- `www/faceai_handoff.php` shows that the current PHP layer is mostly a thin integration bridge, not the core application runtime.
- `faceai/` already proves that a modern Node + Vue application can coexist with the legacy site in this repository.
That means the migration is feasible, but it should be treated as a strangler migration, not a rewrite. The first milestone should not be the race photo page or account flow. Those surfaces are too entangled with the existing Java session model and request beans. The first milestone should be the smallest public-facing page family that lets the team stand up the modern shell, deployment model, routing, and shared UI primitives with minimal business risk.
## What The Current Legacy Surface Looks Like
## 1. JSP is the primary runtime for the public site
The public JSP pages commonly depend on:
- session-scoped beans such as `utenteLogon`, `cart`, `lang`, and `attivita`
- request-scoped beans such as `CR`, `bean`, `list`, `user`, and `cartStatus`
- custom JSP taglibs under `WEB-INF`
- shared includes such as `_inc_header.jsp`, `_inc_footer.jsp`, `_inc_head.jsp`, `_inc_cookie.jsp`, and `_inc_lang.jsp`
This is important because a Node replacement cannot simply port the HTML. It must replace or proxy the data-loading and session-aware behavior currently injected by the JSP runtime.
## 2. PHP exists, but mostly as a sidecar or archive layer
The PHP files in `www/` appear to fall into two main groups:
- FaceAI bridge endpoints such as `faceai_handoff.php`, `faceai_return.php`, and `faceai_config.php`
- older gallery/archive implementations such as `gallery1.php` and `gallery2.php`
The PHP layer is not the main source of identity or page composition. The core public experience is still driven by Java session state and JSP rendering.
## 3. Shared layout and auth state are centralized in includes
The current header include is effectively part layout, part auth adapter, part navigation registry. Migrating individual pages without first defining a modern replacement for the shared shell will create duplicated work.
## 4. Public and admin should be treated as separate migration programs
The `www/admin/` tree shows much heavier use of request beans and CRUD-style JSP pages. That surface should not be bundled into the first modernization wave.
Recommended scope split:
- Wave 1: public site pages under `www/`
- Wave 2: account, cart, and purchase flows
- Wave 3: admin pages under `www/admin/`
## What A Modern Node + Vue Migration Actually Requires
Moving to Node + Vue is not only a frontend change. It requires replacing four legacy runtime responsibilities.
## 1. Presentation layer replacement
Replace JSP-rendered HTML with Vue-rendered pages and components.
## 2. Composition layer replacement
Replace shared includes and taglib-driven composition with a modern shared shell:
- header
- footer
- language switcher
- login/account state widgets
- SEO metadata handling
- analytics and cookie consent hooks
## 3. Session and trust boundary replacement
The modern app should not try to directly consume the legacy Java session cookie. That is the same constraint already identified for FaceAI.
Instead, use a bridge strategy:
- legacy remains source of truth for auth and membership state
- Node receives a signed bootstrap payload or token
- Node establishes its own session or signed server-side state
## 4. Data access replacement
Any migrated page that currently relies on request beans or taglibs will need one of these strategies:
- a new legacy-backed JSON endpoint
- a Node adapter that calls an existing legacy endpoint
- a shared read model or cache built specifically for migrated pages
For the first phases, the safest approach is a thin Node BFF that talks to legacy-owned data sources instead of immediately rewriting all backend logic.
## Recommended Target Architecture
## Recommended runtime
Use a Node-hosted Vue application for the public web layer.
Two workable variants:
1. Nuxt 3 on Node
2. Express BFF plus Vue 3 frontend, similar in spirit to `faceai/`
Recommendation:
- for marketing pages, SEO pages, event listing, and route-by-route incremental replacement, Nuxt 3 is the stronger fit because it gives server rendering, routing, metadata, and hybrid rendering out of the box
- if the team wants to stay very close to the current FaceAI stack, an Express BFF plus Vue frontend is still workable, but it will need extra SSR or pre-render decisions for SEO-sensitive pages
For the plan below, the important decision is not the exact framework. The key is to use a Node server with a Vue UI and a BFF layer that can coexist with the legacy app during migration.
## Proposed logical architecture
- modern web app: Node + Vue public shell and migrated pages
- BFF layer: session bootstrap, legacy adapters, route guards, feature flags
- shared UI package: header, footer, buttons, forms, typography, language switcher
- contracts package: route payloads, auth/session bootstrap schema, event and gallery DTOs
- legacy bridge endpoints: small trusted endpoints on the old stack for auth bootstrap and targeted data reads until the old backend is retired
## Routing model
Adopt strangler routing from the start.
Examples:
- legacy continues serving most routes
- selected routes are forwarded to the new Node app
- migrated pages link back to legacy routes for anything not yet moved
- the cutover is managed by route manifest and feature flags, not by a single big switch
## Recommended repository shape
Create a new top-level workspace area for the modern public site instead of mixing it into `faceai/`.
Suggested structure:
```text
site-modern/
apps/
web/
bff/
packages/
ui/
contracts/
config/
docs/
```
This keeps FaceAI independent while allowing shared contracts or UI conventions later.
## Migration Principles
1. Do not rewrite backend business rules before proving the new web shell.
2. Do not start with the highest-coupling pages.
3. Keep auth and membership checks on the legacy side until the replacement is explicit and tested.
4. Prefer adapter endpoints and signed handoff over trying to share `JSESSIONID` semantics.
5. Migrate by page family, not by random file order.
6. Keep the same URLs where practical, but use route-by-route cutover.
7. Treat public, commerce, and admin as separate tracks.
## Suggested Migration Order
## Phase 0: Inventory And Contracts
Objective: make the migration measurable before writing the new app.
Deliverables:
- route inventory for public pages, account pages, archive pages, and admin pages
- page-family grouping by coupling and risk
- list of shared includes and what state they require
- auth/session bootstrap contract between legacy and Node
- language and SEO requirements inventory
- asset inventory for CSS, JS, images, and fonts still required by public pages
Expected findings:
- static content pages are lowest-risk
- event and listing pages are medium-risk
- race photo pages are high-risk
- login, cart, payment, and admin are highest-risk
## Phase 1: Bare Essentials Scaffold
Objective: stand up the modern app without changing critical business behavior.
Build the following first:
- a Node-hosted Vue application with production build, preview, and deployable runtime
- reverse-proxy or route-switch support so selected URLs can be served by the new app
- a shared layout shell with header, footer, cookie banner hook, and language switcher placeholder
- a minimal BFF service with healthcheck, feature flags, and structured logging
- a legacy session bootstrap endpoint that can tell the Node app whether the user is anonymous or authenticated
- error pages, not found handling, and analytics hooks
This phase should produce a working but small app that can render one safe public route in production-like conditions.
## Phase 2: First Small Slice
Objective: migrate one low-risk public page family end to end.
Recommended first slice:
- `cookies.jsp` and `cookies-en.jsp`
- optionally `privacy.jsp` and `privacy-en.jsp`
Why this is the right first slice:
- low business risk
- low dependence on request beans compared with race pages
- exercises bilingual routing and layout
- validates SEO, metadata, deployment, rollback, and monitoring
- proves the new header/footer shell without needing cart, payment, or race search logic
Alternative first slice if you want something more visible:
- `associazione.jsp` and `associazione-en.jsp`
That is still reasonable, but it likely pulls slightly more shared layout behavior and content formatting into scope than a policy page.
What to complete in this phase:
- route parity for the chosen page pair
- modern content source for both languages
- link compatibility with the rest of the legacy site
- smoke tests for anonymous navigation
- deployment rollback path for only those routes
## Phase 3: Shared Public Shell
Objective: stop rebuilding layout logic page by page.
Extract and implement a reusable modern shell for:
- top navigation
- account/login entry points
- archive navigation
- footer
- cookie and consent surfaces
- language switcher
This is the point where the new app becomes a real replacement surface instead of a one-off page host.
Important constraint:
Do not fully reimplement legacy login in this phase. Only expose login state and links through a bootstrap or adapter mechanism.
## Phase 4: Public Listing Pages
Objective: move pages that are mostly read-only and SEO-sensitive before touching the transactional core.
Candidate page families:
- association and informational pages
- event listing pages
- news or content pages
- archive landing pages
This phase should also define the content model for text-heavy pages so the team stops editing JSP files for editorial content.
## Phase 5: Archive And Gallery Modernization
Objective: retire the PHP gallery islands.
Current signal from the repo:
- `gallery1.jsp` and `gallery2.jsp` are wrappers around PHP pages
- the PHP archive logic likely depends on filesystem-oriented directory browsing behavior
This should be its own dedicated phase because the archive looks simple from the outside but is likely coupled to directory layout and photo-serving assumptions.
Recommended approach:
- first model the archive data shape in a BFF adapter
- then replace the PHP rendering with Vue pages
- keep legacy file serving or image URLs until archive behavior is fully matched
## Phase 6: Race Pages And Search Flow
Objective: move the public race experience after the modern shell and read models are already proven.
This includes pages like `fotoCR.jsp`, which are high-coupling because they currently depend on:
- request beans for race and listing state
- session-aware account checks
- existing JavaScript search functions
- existing modal/download behavior
- FaceAI launch metadata already embedded in JSP
Recommended approach:
- first move race-page rendering to the new app
- keep downloads and final entitlement checks on the legacy side initially
- expose race data and result sets through a BFF or dedicated JSON contract
- treat FaceAI as an already-existing modern island that the new public shell can link to cleanly
This phase is where the new app starts replacing the most valuable legacy experience, but it should not happen before the earlier scaffolding phases are complete.
## Phase 7: Account, Cart, And Checkout
Objective: replace user-sensitive flows only after the public shell is stable.
This phase will likely require explicit decisions about:
- account source of truth
- payment integration ownership
- renewal and membership rules
- session lifecycle across legacy and modern routes
This is not a safe first wave. It is a later migration once the route-switching and session bootstrap patterns are proven.
## Phase 8: Admin Modernization
Objective: move `www/admin/` as a separate program.
The admin surface should probably be treated as a standalone back-office application with its own priorities, permissions, and page patterns. It does not need to block public-site modernization.
## Concrete First Implementation Plan
If the team wants to start now and keep the first move small, the recommended first implementation is:
## Step 1
Create the new modern site workspace and deployment path.
Deliver:
- Node + Vue app scaffold
- BFF service scaffold
- route manifest for migrated URLs
- CI build and deploy pipeline
## Step 2
Implement the shared public shell.
Deliver:
- modern header
- modern footer
- shared typography and spacing tokens
- bilingual route structure
- feature flag to switch specific pages between legacy and modern
## Step 3
Add minimal legacy bootstrap support.
Deliver:
- endpoint for auth state bootstrap
- signed payload contract for user language and basic identity state
- integration tests proving anonymous and logged-in header rendering
## Step 4
Migrate one low-risk page family.
Deliver:
- `cookies` page in both languages, or `privacy` if preferred
- same URL shape or controlled redirect strategy
- smoke tests
- rollback switch
## Step 5
Migrate one slightly more complex public content page.
Deliver:
- `associazione` in both languages
- real shared shell reuse
- proof that future public pages can follow the same pattern
After those five steps, the team will have a real migration foundation rather than a prototype.
## What Will Be Hard
## 1. Auth and session continuity
The legacy site identity is still rooted in Java session state. Replacing JSP without an explicit trust bridge will fail.
## 2. Shared server-side state hidden inside includes
The header and other includes are not pure view fragments. They depend on beans and request/session state.
## 3. Page beans and taglibs
Many pages are really thin shells over server-side Java objects. Those contracts need to be rediscovered and exposed in a modern form.
## 4. SEO and URL preservation
A pure SPA would be the wrong choice for much of the public site. Server rendering or pre-rendering is needed for migrated public routes.
## 5. Archive behavior
The older PHP galleries are probably more coupled to directories and legacy assumptions than they look from the JSP wrappers.
## What Will Be Easier Than It Looks
## 1. Standing up a parallel modern app
The repository already has a working modern precedent in `faceai/`, including Node runtime, Vue frontend, and Docker-based local integration.
## 2. Migrating static and content-heavy pages
Many public informational pages can move early once the shared shell exists.
## 3. Route-by-route rollout
Because the legacy and modern apps can coexist, the migration does not need a freeze or a single cutover date.
## Recommended Non-Goals For The First Wave
Do not do these first:
- full login replacement
- cart and payment rewrite
- admin rewrite
- race search rewrite
- direct database rewrites for everything at once
- a pure client-only SPA for the public site
## Suggested Success Criteria For The First 6 To 8 Weeks
- modern app scaffold is live in a production-like environment
- one to two low-risk bilingual public page families run from the new app
- shared header/footer shell exists and is reusable
- legacy auth bootstrap contract exists
- route switching and rollback are proven
- observability exists for both legacy and modern paths
## Recommended Final Direction
Yes, moving the `www/` part toward Node + Vue is a sound direction, but only if it is implemented as a staged strangler migration.
The right first move is not to reimplement `fotoCR.jsp` or the account stack. The right first move is to establish the new runtime, shared shell, routing model, and auth bootstrap, then migrate a low-risk bilingual page family, then expand into public listing pages, then tackle archives, then move the race/photo experience, and only after that consider account, checkout, and admin.
If the team follows that order, the migration is practical. If the team starts with the race page or tries to replace JSP and PHP everywhere at once, the risk goes up sharply and the delivery cadence will slow down.

View file

@ -1,3 +1,9 @@
@echo off @echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0run_face_encoder.ps1" set "REMOTE_USER=piero"
set "REMOTE_HOST=83.149.164.4"
set "REMOTE_PORT=410"
set "SSH_EXE=C:\Windows\System32\OpenSSH\ssh.exe"
set "SCP_EXE=C:\Windows\System32\OpenSSH\scp.exe"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0run_face_encoder.ps1" -RemoteUser "%REMOTE_USER%" -RemoteHost "%REMOTE_HOST%" -RemotePort "%REMOTE_PORT%" -SshExe "%SSH_EXE%" -ScpExe "%SCP_EXE%"
pause pause

View file

@ -1,21 +1,21 @@
# --- Selezione livello multicore --- param(
Write-Host "" [Parameter(Mandatory = $true)]
Write-Host "Seleziona il livello di multicore per l'elaborazione CPU:" [ValidateNotNullOrEmpty()]
Write-Host " 1 = 1/8 dei core" [string]$RemoteUser,
Write-Host " 2 = 1/4 dei core" [Parameter(Mandatory = $true)]
Write-Host " 3 = 1/2 dei core (predefinito)" [ValidateNotNullOrEmpty()]
Write-Host " 4 = 3/4 dei core" [string]$RemoteHost,
Write-Host " 5 = n-2 core" [Parameter(Mandatory = $true)]
Write-Host "" [ValidateNotNullOrEmpty()]
$multicoreChoice = Read-Host "Inserisci il livello (1-5) oppure premi Invio per usare il predefinito (3)" [string]$RemotePort,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$SshExe,
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$ScpExe
)
if ($multicoreChoice -match '^[1-5]$') {
$multicore = [int]$multicoreChoice
} else {
$multicore = -1
}
# --- Modern folder picker (IFileOpenDialog, Vista+) ---
Add-Type -TypeDefinition @' Add-Type -TypeDefinition @'
using System; using System;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@ -87,63 +87,369 @@ public class ModernFolderPicker {
} }
'@ '@
$inputPath = [ModernFolderPicker]::Show("Select the folder containing images to encode") Add-Type -AssemblyName System.Windows.Forms
$remoteRoots = @(
'/mnt/nas12/nas2/RUS',
'/mnt/da1/foto'
)
$encoderDir = Join-Path $PSScriptRoot 'face_encoder_cpu'
$outputDir = Join-Path $encoderDir 'output'
if (-not $inputPath) { function ConvertTo-PosixSingleQuoted {
Write-Host "No folder selected. Exiting." param(
exit 0 [Parameter(Mandatory = $true)]
[string]$Value
)
return "'" + $Value.Replace("'", "'`"'`"'") + "'"
} }
$inputFolder = Get-Item -LiteralPath $inputPath -ErrorAction Stop function Normalize-RemoteRelativePath {
$raceName = $inputFolder.Name param(
$safeRaceName = ($raceName -replace '[<>:"/\\|?*]', ' ').Trim() [Parameter(Mandatory = $true)]
$safeRaceName = $safeRaceName -replace '\s+', '_' [string]$InputPath
if (-not $safeRaceName) { )
$normalized = $InputPath.Trim()
$normalized = $normalized -replace '\\', '/'
$normalized = $normalized -replace '^/mnt/nas12/nas2/RUS/?', ''
$normalized = $normalized -replace '^/mnt/da1/foto/?', ''
$normalized = $normalized -replace '/+', '/'
$normalized = $normalized.Trim('/')
if (-not $normalized) {
throw 'Il percorso remoto non puo essere vuoto.'
}
$segments = $normalized -split '/'
foreach ($segment in $segments) {
if ([string]::IsNullOrWhiteSpace($segment)) {
throw 'Il percorso remoto contiene segmenti vuoti non validi.'
}
if ($segment -in @('.', '..')) {
throw 'Il percorso remoto non puo contenere . o ...'
}
}
return ($segments -join '/')
}
function Join-RemotePath {
param(
[Parameter(Mandatory = $true)]
[string]$Root,
[Parameter(Mandatory = $true)]
[string]$RelativePath,
[string]$LeafName
)
$parts = @($Root.TrimEnd('/'))
if ($RelativePath) {
$parts += $RelativePath.Trim('/')
}
if ($LeafName) {
$parts += $LeafName
}
return ($parts -join '/')
}
function Get-MulticoreSetting {
Write-Host ''
Write-Host "Seleziona il livello di multicore per l'elaborazione CPU:"
Write-Host ' 1 = 1/8 dei core'
Write-Host ' 2 = 1/4 dei core'
Write-Host ' 3 = 1/2 dei core (predefinito)'
Write-Host ' 4 = 3/4 dei core'
Write-Host ' 5 = n-2 core'
Write-Host ''
$multicoreChoice = Read-Host 'Inserisci il livello (1-5) oppure premi Invio per usare il predefinito (3)'
if ($multicoreChoice -match '^[1-5]$') {
return [int]$multicoreChoice
}
return -1
}
function Show-PklFilePicker {
param(
[Parameter(Mandatory = $true)]
[string]$InitialDirectory
)
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Title = 'Seleziona il file PKL da caricare'
$dialog.Filter = 'Pickle files (*.pkl)|*.pkl|Tutti i file (*.*)|*.*'
$dialog.CheckFileExists = $true
$dialog.Multiselect = $false
if (Test-Path -LiteralPath $InitialDirectory) {
$dialog.InitialDirectory = (Resolve-Path -LiteralPath $InitialDirectory).Path
}
if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
return $null
}
return $dialog.FileName
}
function Invoke-RemoteShellCommand {
param(
[Parameter(Mandatory = $true)]
[string]$Payload,
[switch]$AllocateTty,
[switch]$Quiet
)
$remoteCommand = 'sh -c ' + (ConvertTo-PosixSingleQuoted $Payload)
$sshArgs = [System.Collections.Generic.List[string]]::new()
if ($AllocateTty) {
$sshArgs.Add('-tt')
}
$sshArgs.Add('-p')
$sshArgs.Add($remotePort)
$sshArgs.Add('-o')
$sshArgs.Add('PreferredAuthentications=password')
$sshArgs.Add('-o')
$sshArgs.Add('PubkeyAuthentication=no')
$sshArgs.Add('-o')
$sshArgs.Add('StrictHostKeyChecking=accept-new')
$sshArgs.Add("$remoteUser@$remoteHost")
$sshArgs.Add($remoteCommand)
if ($Quiet) {
& $sshExe @sshArgs | Out-Null
} else {
& $sshExe @sshArgs
}
return $LASTEXITCODE
}
function Test-RemoteFileExists {
param(
[Parameter(Mandatory = $true)]
[string]$RemotePath
)
$payload = 'test -e ' + (ConvertTo-PosixSingleQuoted $RemotePath)
$exitCode = Invoke-RemoteShellCommand -Payload $payload -Quiet
if ($exitCode -eq 0) {
return $true
}
if ($exitCode -eq 1) {
return $false
}
throw "Impossibile verificare l'esistenza del file remoto: $RemotePath"
}
function Test-RemoteDirectoryExists {
param(
[Parameter(Mandatory = $true)]
[string]$RemotePath
)
$payload = 'test -d ' + (ConvertTo-PosixSingleQuoted $RemotePath)
$exitCode = Invoke-RemoteShellCommand -Payload $payload -Quiet
if ($exitCode -eq 0) {
return $true
}
if ($exitCode -eq 1) {
return $false
}
throw "Impossibile verificare il percorso remoto: $RemotePath"
}
function Ensure-RemoteDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$RemoteDirectory
)
$payload = 'mkdir -p ' + (ConvertTo-PosixSingleQuoted $RemoteDirectory)
$exitCode = Invoke-RemoteShellCommand -Payload $payload
if ($exitCode -ne 0) {
throw "Creazione cartella remota non riuscita: $RemoteDirectory"
}
}
function Upload-FileToRemoteTarget {
param(
[Parameter(Mandatory = $true)]
[string]$LocalFile,
[Parameter(Mandatory = $true)]
[string]$RemoteFile,
[bool]$Overwrite
)
$remoteDirectory = Split-Path -Path $RemoteFile -Parent
if ([string]::IsNullOrWhiteSpace($remoteDirectory) -or $remoteDirectory -eq '/') {
throw 'Il percorso remoto destinazione non e valido.'
}
if (Test-RemoteDirectoryExists -RemotePath $RemoteFile) {
throw "La destinazione remota e una cartella, non un file: $RemoteFile"
}
Ensure-RemoteDirectory -RemoteDirectory $remoteDirectory
if ((-not $Overwrite) -and (Test-RemoteFileExists -RemotePath $RemoteFile)) {
Write-Host "Salto upload per file esistente: $RemoteFile"
return
}
$scpArgs = @(
'-P', $remotePort,
'-o', 'PreferredAuthentications=password',
'-o', 'PubkeyAuthentication=no',
'-o', 'StrictHostKeyChecking=accept-new',
$LocalFile,
("{0}@{1}:{2}" -f $remoteUser, $remoteHost, (ConvertTo-PosixSingleQuoted $RemoteFile))
)
& $scpExe @scpArgs
if ($LASTEXITCODE -ne 0) {
throw "Caricamento non riuscito verso $RemoteFile"
}
}
function Invoke-FaceEncoding {
$multicore = Get-MulticoreSetting
$inputPath = [ModernFolderPicker]::Show('Select the folder containing images to encode')
if (-not $inputPath) {
Write-Host 'Nessuna cartella selezionata. Uscita.'
return 0
}
$inputFolder = Get-Item -LiteralPath $inputPath -ErrorAction Stop
$raceName = $inputFolder.Name
$safeRaceName = ($raceName -replace '[<>:"/\\|?*]', ' ').Trim()
$safeRaceName = $safeRaceName -replace '\s+', '_'
if (-not $safeRaceName) {
$safeRaceName = 'race' $safeRaceName = 'race'
} }
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$encoderDir = Join-Path $PSScriptRoot "face_encoder_cpu" $outputFile = Join-Path $outputDir ("face_encodings_{0}_{1}.pkl" -f $timestamp, $safeRaceName)
$outputDir = Join-Path $encoderDir "output" $logFile = Join-Path $outputDir ("encoder_log_{0}_{1}.txt" -f $timestamp, $safeRaceName)
$outputFile = Join-Path $outputDir ("face_encodings_{0}_{1}.pkl" -f $timestamp, $safeRaceName)
$logFile = Join-Path $outputDir ("encoder_log_{0}_{1}.txt" -f $timestamp, $safeRaceName)
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# --- Build argument list --- $encoderExe = Join-Path $encoderDir 'face_encoder_cpu.exe'
$encoderExe = Join-Path $encoderDir "face_encoder_cpu.exe" $encoderArgs = [System.Collections.Generic.List[string]]::new()
$encoderArgs.Add('-i')
$encoderArgs.Add($inputFolder.FullName)
$encoderArgs.Add('-r')
$encoderArgs.Add('-o')
$encoderArgs.Add($outputFile)
$encoderArgs.Add('-l')
$encoderArgs.Add($logFile)
$encoderArgs = [System.Collections.Generic.List[string]]::new() if ($multicore -ge 0) {
$encoderArgs.Add("-i") $encoderArgs.Add('-m')
$encoderArgs.Add($inputFolder.FullName)
$encoderArgs.Add("-r")
$encoderArgs.Add("-o")
$encoderArgs.Add($outputFile)
$encoderArgs.Add("-l")
$encoderArgs.Add($logFile)
if ($multicore -ge 0) {
$encoderArgs.Add("-m")
$encoderArgs.Add([string]$multicore) $encoderArgs.Add([string]$multicore)
} }
# --- Run encoder --- Write-Host "Input folder : $($inputFolder.FullName)"
Write-Host "Input folder : $($inputFolder.FullName)" Write-Host "Race name : $raceName"
Write-Host "Race name : $raceName" Write-Host "Multicore : $(if ($multicore -ge 0) { $multicore } else { 'default (3)' })"
Write-Host "Multicore : $(if ($multicore -ge 0) { $multicore } else { 'default (3)' })" Write-Host "Output file : $outputFile"
Write-Host "Output file : $outputFile" Write-Host "Log file : $logFile"
Write-Host "Log file : $logFile" Write-Host "Command : $encoderExe $encoderArgs"
Write-Host "Command : $encoderExe $encoderArgs" Write-Host ''
Write-Host ""
& $encoderExe @encoderArgs & $encoderExe @encoderArgs
$encoderExitCode = $LASTEXITCODE
$encoderExitCode = $LASTEXITCODE if ($encoderExitCode -eq 0 -and (Test-Path -LiteralPath $outputFile)) {
if ($encoderExitCode -eq 0 -and (Test-Path -LiteralPath $outputFile)) {
Start-Process explorer.exe "/select,`"$outputFile`"" Start-Process explorer.exe "/select,`"$outputFile`""
} elseif ($encoderExitCode -eq 0) { } elseif ($encoderExitCode -eq 0) {
Write-Warning "Encoding completed, but the expected output file was not found: $outputFile" Write-Warning "Encoding completed, but the expected output file was not found: $outputFile"
}
return $encoderExitCode
} }
exit $encoderExitCode function Invoke-PklUpload {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
$selectedFile = Show-PklFilePicker -InitialDirectory $outputDir
if (-not $selectedFile) {
Write-Host 'Nessun file selezionato. Uscita.'
return 0
}
$fileInfo = Get-Item -LiteralPath $selectedFile -ErrorAction Stop
$relativeInput = Read-Host 'Inserisci il percorso relativo sotto RUS/foto (esempio: 2026/04.APRILE/ISOLOTTO)'
$relativePath = Normalize-RemoteRelativePath -InputPath $relativeInput
$targets = foreach ($root in $remoteRoots) {
[pscustomobject]@{
Root = $root
RemoteFile = Join-RemotePath -Root $root -RelativePath $relativePath -LeafName $fileInfo.Name
}
}
Write-Host ''
Write-Host 'Destinazioni remote:'
foreach ($target in $targets) {
Write-Host (" - {0}" -f $target.RemoteFile)
}
Write-Host ''
$existingTargets = @($targets | Where-Object { Test-RemoteFileExists -RemotePath $_.RemoteFile })
$overwrite = $false
if ($existingTargets.Count -gt 0) {
Write-Host 'Il file esiste gia nelle seguenti destinazioni:'
foreach ($target in $existingTargets) {
Write-Host (" - {0}" -f $target.RemoteFile)
}
$choice = Read-Host 'Vuoi sovrascrivere i file esistenti? (s/N)'
if ($choice -match '^(s|si|y|yes)$') {
$overwrite = $true
}
}
foreach ($target in $targets) {
Upload-FileToRemoteTarget -LocalFile $fileInfo.FullName -RemoteFile $target.RemoteFile -Overwrite:$overwrite
}
Write-Host ''
Write-Host 'Caricamento completato.'
return 0
}
Write-Host ''
Write-Host 'Seleziona un''opzione:'
Write-Host ' 1. Elaborazione riconoscimento facciale'
Write-Host ' 2. Caricamento'
Write-Host ''
$mode = $null
while ($mode -notin @('1', '2')) {
$mode = Read-Host 'Inserisci 1 o 2'
}
try {
if ($mode -eq '1') {
$exitCode = Invoke-FaceEncoding
} else {
$exitCode = Invoke-PklUpload
}
exit $exitCode
} catch {
Write-Error $_
exit 1
}

View file

@ -6,10 +6,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
isWorking: {
type: Boolean,
required: true
},
isProcessingSearch: { isProcessingSearch: {
type: Boolean, type: Boolean,
required: true required: true
@ -42,7 +38,23 @@ const props = defineProps({
type: String, type: String,
required: true required: true
}, },
canStartSearch: { panelMode: {
type: String,
required: true
},
errorMessage: {
type: String,
default: ''
},
raceAvailabilityMessage: {
type: String,
default: ''
},
consentAccepted: {
type: Boolean,
required: true
},
consentChecked: {
type: Boolean, type: Boolean,
required: true required: true
}, },
@ -57,6 +69,8 @@ const props = defineProps({
}); });
const emit = defineEmits([ const emit = defineEmits([
'toggle-consent',
'accept-consent',
'open-file-picker', 'open-file-picker',
'file-change', 'file-change',
'drag-enter', 'drag-enter',
@ -64,13 +78,18 @@ const emit = defineEmits([
'drag-leave', 'drag-leave',
'drop', 'drop',
'clear-file', 'clear-file',
'submit-search', 'retry',
'return-to-legacy' 'return-to-legacy'
]); ]);
</script> </script>
<template> <template>
<section class="faceai-panel shadow-sm"> <section class="faceai-panel shadow-sm">
<div v-if="session?.race?.name" class="faceai-race-heading">
<span class="faceai-race-label">{{ t('raceLabel') }}</span>
<h1 class="faceai-race-name">{{ session.race.name }}</h1>
</div>
<div v-if="loading" class="faceai-loading-state"> <div v-if="loading" class="faceai-loading-state">
<span class="spinner-border text-warning" role="status" aria-hidden="true"></span> <span class="spinner-border text-warning" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span> <span>{{ busyLabel }}</span>
@ -82,18 +101,77 @@ const emit = defineEmits([
<a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a> <a class="btn btn-warning" :href="simulatorUrl">{{ t('openSimulator') }}</a>
</div> </div>
<template v-else-if="panelMode === 'unavailable'">
<div class="faceai-state-card faceai-state-card-error">
<h2 class="faceai-section-title">{{ t('uploaderUnavailableTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ raceAvailabilityMessage || errorMessage || t('unavailableDefault') }}</p>
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
</div>
<p v-if="errorMessage && errorMessage !== raceAvailabilityMessage" class="faceai-inline-error mb-0">{{ errorMessage }}</p>
</template>
<template v-else-if="panelMode === 'consent'">
<div class="faceai-state-card faceai-state-card-consent">
<h2 class="faceai-section-title">{{ t('uploaderConsentTitle') }}</h2>
<p class="faceai-disclaimer-text">{{ t('disclaimerBody') }}</p>
<label class="faceai-consent-check">
<input
type="checkbox"
:checked="consentChecked"
@change="emit('toggle-consent', $event.target.checked)"
/>
<span>{{ t('consentCheckbox') }}</span>
</label>
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('accept-consent')">
{{ t('agreeButton') }}
</button>
<button class="btn btn-light faceai-secondary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
<p v-if="errorMessage" class="faceai-inline-error mb-0">{{ errorMessage }}</p>
</div>
</template>
<template v-else-if="panelMode === 'processing'">
<div class="faceai-state-card faceai-state-card-processing" aria-live="polite" aria-busy="true">
<span class="faceai-spinner faceai-spinner-lg" role="status" aria-hidden="true"></span>
<h2 class="faceai-section-title">{{ t('uploaderProcessingTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ busyLabel }}</p>
</div>
</template>
<template v-else-if="panelMode === 'error'">
<div class="faceai-state-card faceai-state-card-error">
<h2 class="faceai-section-title">{{ t('uploaderErrorTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ errorMessage }}</p>
<div class="faceai-action-row faceai-action-row-stacked">
<button class="btn btn-warning btn-lg faceai-primary-action" type="button" @click="emit('retry')">
{{ t('retryButton') }}
</button>
<button class="btn btn-light faceai-secondary-action" type="button" @click="emit('return-to-legacy')">
{{ t('backButton') }}
</button>
</div>
</div>
</template>
<template v-else> <template v-else>
<div class="faceai-panel-header"> <div class="faceai-panel-header">
<div> <div>
<h2 class="faceai-section-title mb-2">{{ t('uploaderTitle') }}</h2> <h2 class="faceai-section-title mb-2">{{ t('uploaderReadyTitle') }}</h2>
<p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p> <p class="faceai-panel-subtitle mb-0">{{ t('uploaderHint') }}</p>
</div> </div>
</div> </div>
<div v-if="isWorking && busyLabel" class="faceai-busy-banner" aria-live="polite"> <p v-if="errorMessage" class="faceai-inline-error">{{ errorMessage }}</p>
<span class="faceai-spinner" role="status" aria-hidden="true"></span>
<span>{{ busyLabel }}</span>
</div>
<div <div
class="faceai-dropzone" class="faceai-dropzone"
@ -165,16 +243,9 @@ const emit = defineEmits([
<span>{{ t('uploaderDragActive') }}</span> <span>{{ t('uploaderDragActive') }}</span>
</div> </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>
<div class="faceai-action-row"> <div class="faceai-action-row">
<button v-if="selectedFile" class="btn btn-warning" type="button" :disabled="!canStartSearch" @click="emit('submit-search')">
{{ t('uploadButton') }}
</button>
<button class="btn btn-light" type="button" @click="emit('return-to-legacy')">{{ t('backButton') }}</button> <button class="btn btn-light" type="button" @click="emit('return-to-legacy')">{{ t('backButton') }}</button>
</div> </div>
@ -187,12 +258,35 @@ const emit = defineEmits([
<style scoped> <style scoped>
.faceai-panel { .faceai-panel {
max-width: 760px;
margin: 0 auto;
border-radius: 28px; border-radius: 28px;
padding: 1.5rem; padding: 1.5rem;
background: #fffdf9; background: #fffdf9;
border: 1px solid rgba(212, 189, 154, 0.55); border: 1px solid rgba(212, 189, 154, 0.55);
} }
.faceai-race-heading {
margin-bottom: 1.35rem;
}
.faceai-race-label {
display: inline-block;
margin-bottom: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #9a6a19;
}
.faceai-race-name {
margin: 0;
font-size: clamp(1.4rem, 4vw, 2.1rem);
line-height: 1.12;
color: #30261e;
}
.faceai-panel-header { .faceai-panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -206,12 +300,63 @@ const emit = defineEmits([
color: #30261e; color: #30261e;
} }
.faceai-state-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.faceai-state-card-processing {
align-items: center;
justify-content: center;
min-height: 320px;
text-align: center;
}
.faceai-panel-subtitle, .faceai-panel-subtitle,
.faceai-dropzone-copy, .faceai-dropzone-copy,
.faceai-subtle-note { .faceai-subtle-note {
color: #665548; color: #665548;
} }
.faceai-disclaimer-text {
margin: 0;
padding: 1rem 1.1rem;
border-radius: 18px;
border: 1px solid rgba(191, 158, 117, 0.28);
background: rgba(255, 249, 238, 0.92);
color: #4b3a2e;
line-height: 1.6;
white-space: pre-line;
}
.faceai-consent-check {
display: flex;
align-items: flex-start;
gap: 0.8rem;
color: #30261e;
font-weight: 600;
}
.faceai-consent-check input {
width: 1.2rem;
height: 1.2rem;
margin-top: 0.12rem;
accent-color: #d58a00;
}
.faceai-inline-error {
margin: 0 0 1rem;
color: #a53e24;
font-weight: 600;
}
.faceai-primary-action,
.faceai-secondary-action {
width: 100%;
}
.faceai-busy-banner { .faceai-busy-banner {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -372,6 +517,10 @@ const emit = defineEmits([
margin-top: 1.25rem; margin-top: 1.25rem;
} }
.faceai-action-row-stacked {
margin-top: 0;
}
.faceai-subtle-note { .faceai-subtle-note {
margin-top: 0.85rem; margin-top: 0.85rem;
font-size: 0.95rem; font-size: 0.95rem;

View file

@ -1,6 +1,9 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { legacyUrl } from '../legacyUrls.js'; import { legacyUrl } from '../legacyUrls.js';
const FACEAI_DISCLAIMER_COOKIE = 'faceai_biometric_notice_v1';
const FACEAI_DISCLAIMER_COOKIE_MAX_AGE = 60 * 60 * 24 * 180;
const copy = { const copy = {
it: { it: {
pageTitle: 'Face ID', pageTitle: 'Face ID',
@ -25,22 +28,31 @@ const copy = {
uploaderSelected: 'File selezionato', uploaderSelected: 'File selezionato',
uploaderReplace: 'Sostituisci', uploaderReplace: 'Sostituisci',
uploaderRemove: 'Rimuovi', uploaderRemove: 'Rimuovi',
uploaderReadyTitle: 'Carica il tuo selfie',
uploaderProcessingTitle: 'Ricerca in corso',
uploaderErrorTitle: 'Ricerca non completata',
uploaderUnavailableTitle: 'Face ID non disponibile',
uploaderConsentTitle: 'Prima di continuare',
backButton: 'Torna alla pagina gara', backButton: 'Torna alla pagina gara',
uploadButton: 'Avvia ricerca Face ID', retryButton: 'Riprova',
agreeButton: 'Accetto e continuo',
consentCheckbox: 'Confermo di aver letto linformativa sul trattamento dei dati biometrici.',
disclaimerBody: 'Trattamento dati biometrici per ricerca facciale\nI dati biometrici sono trattati nel rispetto del GDPR esclusivamente per la ricerca e conservati solo per il tempo strettamente necessario alla ricerca (30” al massimo). Non vengono ceduti a terzi né utilizzati per finalità pubblicitarie.',
consentRequired: 'Per continuare devi accettare linformativa.',
openSimulator: 'Apri il simulatore legacy', openSimulator: 'Apri il simulatore legacy',
handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.', handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.',
sessionLoading: 'Caricamento della sessione FaceAI…', sessionLoading: 'Caricamento della sessione FaceAI…',
submitLoading: 'Invio del selfie e preparazione della ricerca…', submitLoading: 'Invio del selfie e preparazione della ricerca…',
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…', redirectLoading: 'Apertura della pagina gara filtrata…',
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…', processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
unavailableDefault: 'FaceAI non è disponibile per questa gara.', unavailableDefault: 'FaceAI non è disponibile per questa gara.',
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.', noFacesFoundMessage: 'Non sono state trovate foto corrispondenti con il selfie caricato. Puoi tornare alla gara oppure riprovare con unaltra immagine.',
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.', readyMessage: 'Seleziona un selfie per avviare subito la ricerca nella gara corrente.',
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.', completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.', failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
matchesLabel: 'Foto trovate', matchesLabel: 'Foto trovate',
redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…', redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…',
noFileCta: 'Seleziona unimmagine per sbloccare la ricerca.', noFileCta: 'Seleziona unimmagine dal dispositivo o trascinala qui per iniziare.',
invalidImage: 'Seleziona un file immagine valido.', invalidImage: 'Seleziona un file immagine valido.',
pollError: 'Impossibile leggere lo stato della ricerca.', pollError: 'Impossibile leggere lo stato della ricerca.',
searchFailed: 'La ricerca non è andata a buon fine.', searchFailed: 'La ricerca non è andata a buon fine.',
@ -78,22 +90,31 @@ const copy = {
uploaderSelected: 'Selected file', uploaderSelected: 'Selected file',
uploaderReplace: 'Replace', uploaderReplace: 'Replace',
uploaderRemove: 'Remove', uploaderRemove: 'Remove',
uploaderReadyTitle: 'Upload your selfie',
uploaderProcessingTitle: 'Search in progress',
uploaderErrorTitle: 'Search not completed',
uploaderUnavailableTitle: 'Face ID unavailable',
uploaderConsentTitle: 'Before you continue',
backButton: 'Back to the race page', backButton: 'Back to the race page',
uploadButton: 'Start Face ID search', retryButton: 'Try again',
agreeButton: 'I agree and continue',
consentCheckbox: 'I confirm that I have read the biometric data processing notice.',
disclaimerBody: 'Biometric data processing for facial search\nBiometric data is processed in compliance with GDPR exclusively for facial search and kept only for the time strictly necessary to perform the search (30 seconds maximum). It is not shared with third parties and is not used for advertising purposes.',
consentRequired: 'You must accept the notice before continuing.',
openSimulator: 'Open the legacy simulator', openSimulator: 'Open the legacy simulator',
handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.', handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.',
sessionLoading: 'Loading the FaceAI session…', sessionLoading: 'Loading the FaceAI session…',
submitLoading: 'Uploading the selfie and preparing the search…', submitLoading: 'Uploading the selfie and preparing the search…',
redirectLoading: 'Redirecting to the filtered legacy page…', redirectLoading: 'Opening the filtered race page…',
processingLoading: 'Biometric search in progress across all race photos…', processingLoading: 'Biometric search in progress across all race photos…',
unavailableDefault: 'FaceAI is not available for this race.', unavailableDefault: 'FaceAI is not available for this race.',
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.', noFacesFoundMessage: 'No matching photos were found for the uploaded selfie. You can go back to the race page or try another image.',
readyMessage: 'Select a selfie to start a search limited to the current race.', readyMessage: 'Select a selfie to start the search immediately for the current race.',
completedMessage: 'Search completed. Found {count} matching photos.', completedMessage: 'Search completed. Found {count} matching photos.',
failedMessage: 'The search did not complete. Check the message and try again.', failedMessage: 'The search did not complete. Check the message and try again.',
matchesLabel: 'Photos found', matchesLabel: 'Photos found',
redirectMessage: 'Redirecting to the filtered legacy page…', redirectMessage: 'Redirecting to the filtered legacy page…',
noFileCta: 'Select an image to unlock the search action.', noFileCta: 'Select an image from your device or drag it here to begin.',
invalidImage: 'Select a valid image file.', invalidImage: 'Select a valid image file.',
pollError: 'Unable to read the search status.', pollError: 'Unable to read the search status.',
searchFailed: 'The search failed.', searchFailed: 'The search failed.',
@ -153,6 +174,25 @@ function buildLegacyReturnUrl(url) {
} }
} }
function hasAcceptedDisclaimerCookie() {
if (typeof document === 'undefined') {
return false;
}
return document.cookie
.split(';')
.map((part) => part.trim())
.some((part) => part === `${FACEAI_DISCLAIMER_COOKIE}=1`);
}
function persistAcceptedDisclaimerCookie() {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${FACEAI_DISCLAIMER_COOKIE}=1; Max-Age=${FACEAI_DISCLAIMER_COOKIE_MAX_AGE}; Path=/; SameSite=Lax`;
}
export function useFaceAiHome() { export function useFaceAiHome() {
const session = ref(null); const session = ref(null);
const loading = ref(true); const loading = ref(true);
@ -164,6 +204,9 @@ export function useFaceAiHome() {
const isRedirecting = ref(false); const isRedirecting = ref(false);
const isDragging = ref(false); const isDragging = ref(false);
const fileInput = ref(null); const fileInput = ref(null);
const consentAccepted = ref(hasAcceptedDisclaimerCookie());
const consentChecked = ref(false);
const panelErrorMode = ref('');
let pollTimer = null; let pollTimer = null;
let dragDepth = 0; let dragDepth = 0;
@ -256,13 +299,53 @@ export function useFaceAiHome() {
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing'); const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing'); const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
const raceAvailability = computed(() => session.value?.availability || null); const raceAvailability = computed(() => session.value?.availability || null);
const raceAvailabilityMessage = computed(() => {
if (!raceAvailability.value || raceAvailability.value.available) {
return '';
}
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
});
const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value); const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value);
const hasSearchFailure = computed(() => panelErrorMode.value === 'failed' || panelErrorMode.value === 'no-results');
const showConsent = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && session.value?.access?.faceAiAllowed && !consentAccepted.value);
const panelMode = computed(() => {
if (loading.value) {
return 'loading';
}
if (!session.value) {
return 'missing-session';
}
if (!session.value.access?.faceAiAllowed || raceAvailability.value?.available === false) {
return 'unavailable';
}
if (showConsent.value) {
return 'consent';
}
if (isProcessingSearch.value || isRedirecting.value) {
return 'processing';
}
if (hasSearchFailure.value) {
return 'error';
}
return 'upload';
});
const canStartSearch = computed(() => { const canStartSearch = computed(() => {
if (!session.value || !selectedFile.value) { if (!session.value || !selectedFile.value) {
return false; return false;
} }
if (!consentAccepted.value) {
return false;
}
if (!session.value.access?.faceAiAllowed) { if (!session.value.access?.faceAiAllowed) {
return false; return false;
} }
@ -368,6 +451,11 @@ export function useFaceAiHome() {
selectedFile.value = file; selectedFile.value = file;
errorMessage.value = ''; errorMessage.value = '';
panelErrorMode.value = '';
if (canStartSearch.value) {
submitSearch();
}
} }
function clearSelectedFile() { function clearSelectedFile() {
@ -377,6 +465,38 @@ export function useFaceAiHome() {
} }
} }
function resetForRetry() {
errorMessage.value = '';
redirectUrl.value = '';
activeSearch.value = null;
isSubmitting.value = false;
isRedirecting.value = false;
panelErrorMode.value = '';
clearSelectedFile();
}
function setConsentChecked(value) {
consentChecked.value = Boolean(value);
if (errorMessage.value === t('consentRequired')) {
errorMessage.value = '';
}
}
function acceptDisclaimer() {
if (consentAccepted.value) {
return;
}
if (!consentChecked.value) {
errorMessage.value = t('consentRequired');
return;
}
consentAccepted.value = true;
errorMessage.value = '';
persistAcceptedDisclaimerCookie();
}
function onFileChange(event) { function onFileChange(event) {
setSelectedFile(event.target.files?.[0] || null); setSelectedFile(event.target.files?.[0] || null);
} }
@ -439,6 +559,7 @@ export function useFaceAiHome() {
session.value = await response.json(); session.value = await response.json();
loading.value = false; loading.value = false;
consentAccepted.value = hasAcceptedDisclaimerCookie();
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) { if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData'); errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
reportInvalidRaceAvailability(session.value.availability); reportInvalidRaceAvailability(session.value.availability);
@ -452,6 +573,7 @@ export function useFaceAiHome() {
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
errorMessage.value = localizeServerError(payload, 'pollError'); errorMessage.value = localizeServerError(payload, 'pollError');
isSubmitting.value = false; isSubmitting.value = false;
panelErrorMode.value = 'failed';
logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload }); logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload });
return; return;
} }
@ -461,32 +583,35 @@ export function useFaceAiHome() {
if (activeSearch.value.status === 'failed') { if (activeSearch.value.status === 'failed') {
isSubmitting.value = false; isSubmitting.value = false;
errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed'); errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed');
panelErrorMode.value = 'failed';
return; return;
} }
if (activeSearch.value.status === 'completed') { if (activeSearch.value.status === 'completed') {
isSubmitting.value = false; isSubmitting.value = false;
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') { if (activeSearch.value.completionCode === 'NO_FACES_FOUND' || Number(activeSearch.value.matchCount || 0) === 0) {
isRedirecting.value = false; isRedirecting.value = false;
redirectUrl.value = ''; redirectUrl.value = '';
panelErrorMode.value = 'no-results';
clearSelectedFile(); clearSelectedFile();
errorMessage.value = t('noFacesFoundMessage');
logFaceAiDebug('Search completed without detectable faces', { searchId }); logFaceAiDebug('Search completed without detectable faces', { searchId });
return; return;
} }
errorMessage.value = '';
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' }); const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json(); const payload = await redirectResponse.json();
if (!redirectResponse.ok) { if (!redirectResponse.ok) {
errorMessage.value = localizeServerError(payload, 'redirectError'); errorMessage.value = localizeServerError(payload, 'redirectError');
panelErrorMode.value = 'failed';
logFaceAiDebug('Redirect build failed', { searchId, payload }); logFaceAiDebug('Redirect build failed', { searchId, payload });
return; return;
} }
redirectUrl.value = payload.url; redirectUrl.value = payload.url;
isRedirecting.value = true; isRedirecting.value = true;
logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url }); logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url });
window.setTimeout(() => { window.location.replace(payload.url);
window.location.href = payload.url;
}, 1200);
return; return;
} }
@ -499,6 +624,7 @@ export function useFaceAiHome() {
errorMessage.value = ''; errorMessage.value = '';
redirectUrl.value = ''; redirectUrl.value = '';
isRedirecting.value = false; isRedirecting.value = false;
panelErrorMode.value = '';
if (!selectedFile.value) { if (!selectedFile.value) {
errorMessage.value = t('chooseSelfie'); errorMessage.value = t('chooseSelfie');
@ -510,6 +636,11 @@ export function useFaceAiHome() {
return; return;
} }
if (!consentAccepted.value) {
errorMessage.value = t('consentRequired');
return;
}
isSubmitting.value = true; isSubmitting.value = true;
const formData = new FormData(); const formData = new FormData();
@ -526,6 +657,7 @@ export function useFaceAiHome() {
if (!response.ok) { if (!response.ok) {
errorMessage.value = localizeServerError(payload, 'searchCreateError'); errorMessage.value = localizeServerError(payload, 'searchCreateError');
isSubmitting.value = false; isSubmitting.value = false;
panelErrorMode.value = 'failed';
logFaceAiDebug('Search creation failed', { status: response.status, payload }); logFaceAiDebug('Search creation failed', { status: response.status, payload });
return; return;
} }
@ -555,6 +687,8 @@ export function useFaceAiHome() {
canPickFile, canPickFile,
canStartSearch, canStartSearch,
clearSelectedFile, clearSelectedFile,
consentAccepted,
consentChecked,
currentLocale, currentLocale,
errorMessage, errorMessage,
fileInput, fileInput,
@ -570,11 +704,16 @@ export function useFaceAiHome() {
onDrop, onDrop,
onFileChange, onFileChange,
openFilePicker, openFilePicker,
panelMode,
raceAvailabilityMessage,
resetForRetry,
redirectUrl, redirectUrl,
returnToLegacy, returnToLegacy,
acceptDisclaimer,
selectedFile, selectedFile,
selectedFileSizeLabel, selectedFileSizeLabel,
session, session,
setConsentChecked,
simulatorUrl, simulatorUrl,
statusLabel, statusLabel,
submitSearch, submitSearch,

View file

@ -1,23 +1,18 @@
<script setup> <script setup>
import LegacyHeader from '../components/LegacyHeader.vue'; import LegacyHeader from '../components/LegacyHeader.vue';
import FaceAiFeedbackPanel from '../components/FaceAiFeedbackPanel.vue';
import FaceAiHeroCard from '../components/FaceAiHeroCard.vue';
import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue'; import FaceAiUploadPanel from '../components/FaceAiUploadPanel.vue';
import { useFaceAiHome } from '../composables/useFaceAiHome.js'; import { useFaceAiHome } from '../composables/useFaceAiHome.js';
const { const {
activeSearch,
activeSearchStatusLabel,
busyLabel, busyLabel,
canPickFile, canPickFile,
canStartSearch,
clearSelectedFile, clearSelectedFile,
currentLocale, consentAccepted,
consentChecked,
errorMessage, errorMessage,
fileInput, fileInput,
isDragging, isDragging,
isProcessingSearch, isProcessingSearch,
isWorking,
loading, loading,
onDragEnter, onDragEnter,
onDragLeave, onDragLeave,
@ -25,14 +20,16 @@ const {
onDrop, onDrop,
onFileChange, onFileChange,
openFilePicker, openFilePicker,
redirectUrl, panelMode,
raceAvailabilityMessage,
resetForRetry,
returnToLegacy, returnToLegacy,
selectedFile, selectedFile,
selectedFileSizeLabel, selectedFileSizeLabel,
session, session,
simulatorUrl, simulatorUrl,
statusLabel, acceptDisclaimer,
submitSearch, setConsentChecked,
t t
} = useFaceAiHome(); } = useFaceAiHome();
@ -46,18 +43,10 @@ function assignFileInput(element) {
<LegacyHeader /> <LegacyHeader />
<div class="container my-3 faceai-page"> <div class="container my-3 faceai-page">
<FaceAiHeroCard <div class="row mt-4 justify-content-center">
:session="session"
:current-locale="currentLocale"
:active-search-status-label="activeSearchStatusLabel"
:t="t"
/>
<div class="row mt-4">
<div class="col-12"> <div class="col-12">
<FaceAiUploadPanel <FaceAiUploadPanel
:loading="loading" :loading="loading"
:is-working="isWorking"
:is-processing-search="isProcessingSearch" :is-processing-search="isProcessingSearch"
:session="session" :session="session"
:simulator-url="simulatorUrl" :simulator-url="simulatorUrl"
@ -66,9 +55,15 @@ function assignFileInput(element) {
:is-dragging="isDragging" :is-dragging="isDragging"
:selected-file="selectedFile" :selected-file="selectedFile"
:selected-file-size-label="selectedFileSizeLabel" :selected-file-size-label="selectedFileSizeLabel"
:can-start-search="canStartSearch" :panel-mode="panelMode"
:error-message="errorMessage"
:race-availability-message="raceAvailabilityMessage"
:consent-accepted="consentAccepted"
:consent-checked="consentChecked"
:assign-file-input="assignFileInput" :assign-file-input="assignFileInput"
:t="t" :t="t"
@toggle-consent="setConsentChecked"
@accept-consent="acceptDisclaimer"
@open-file-picker="openFilePicker" @open-file-picker="openFilePicker"
@file-change="onFileChange" @file-change="onFileChange"
@drag-enter="onDragEnter" @drag-enter="onDragEnter"
@ -76,21 +71,11 @@ function assignFileInput(element) {
@drag-leave="onDragLeave" @drag-leave="onDragLeave"
@drop="onDrop" @drop="onDrop"
@clear-file="clearSelectedFile" @clear-file="clearSelectedFile"
@submit-search="submitSearch" @retry="resetForRetry"
@return-to-legacy="returnToLegacy" @return-to-legacy="returnToLegacy"
/> />
</div> </div>
</div> </div>
<FaceAiFeedbackPanel
:status-label="statusLabel"
:is-working="isWorking"
:busy-label="busyLabel"
:active-search="activeSearch"
:redirect-url="redirectUrl"
:error-message="errorMessage"
:t="t"
/>
</div> </div>
</main> </main>
</template> </template>