diff --git a/backend/config/packages/liip_imagine.yaml b/backend/config/packages/liip_imagine.yaml index b58115b..df93969 100644 --- a/backend/config/packages/liip_imagine.yaml +++ b/backend/config/packages/liip_imagine.yaml @@ -1,7 +1,7 @@ liip_imagine: twig: mode: lazy - driver: gd + driver: imagick resolvers: default: web_path: @@ -14,6 +14,7 @@ liip_imagine: filter_sets: avatar_thumb: quality: 85 + format: webp filters: thumbnail: size: [200, 200] @@ -22,6 +23,7 @@ liip_imagine: ~ avatar_full: quality: 90 + format: webp filters: thumbnail: size: [400, 400] diff --git a/backend/config/services.yaml b/backend/config/services.yaml index ecb0a04..4f3d986 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -23,7 +23,7 @@ services: arguments: $resolvers: !tagged_iterator app.contact_resolver - App\Command\MigratePicturesCommand: + App\Command\DeployResetCommand: arguments: $projectDir: '%kernel.project_dir%' diff --git a/backend/public/media/cache/avatar_full/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg b/backend/public/media/cache/avatar_full/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg index a062eec..61ff6ac 100644 Binary files a/backend/public/media/cache/avatar_full/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg and b/backend/public/media/cache/avatar_full/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg differ diff --git a/backend/public/media/cache/avatar_full/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg b/backend/public/media/cache/avatar_full/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg index c96261c..f56ed5e 100644 Binary files a/backend/public/media/cache/avatar_full/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg and b/backend/public/media/cache/avatar_full/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg differ diff --git a/backend/public/media/cache/avatar_thumb/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg b/backend/public/media/cache/avatar_thumb/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg index 1ede44d..d7dd73d 100644 Binary files a/backend/public/media/cache/avatar_thumb/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg and b/backend/public/media/cache/avatar_thumb/uploads/avatars/04d04249801564c92579b9f8237de49a2c03e1a1.jpg differ diff --git a/backend/public/media/cache/avatar_thumb/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg b/backend/public/media/cache/avatar_thumb/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg index 43b1444..1fa9caf 100644 Binary files a/backend/public/media/cache/avatar_thumb/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg and b/backend/public/media/cache/avatar_thumb/uploads/avatars/f85ab55cafa6746471f601a71c1d7ccc425a37c5.jpg differ diff --git a/backend/src/Command/DeployResetCommand.php b/backend/src/Command/DeployResetCommand.php new file mode 100644 index 0000000..30a28bb --- /dev/null +++ b/backend/src/Command/DeployResetCommand.php @@ -0,0 +1,88 @@ +section('Invalidation des sessions'); + + $sessionDir = $this->projectDir . '/var/sessions'; + $cleared = 0; + + if (is_dir($sessionDir)) { + $finder = new Finder(); + $finder->files()->in($sessionDir); + + foreach ($finder as $file) { + $path = $file->getRealPath(); + + if ($path !== false && is_file($path) && unlink($path)) { + ++$cleared; + } + } + } + + $io->writeln(sprintf(' %d session(s) supprimée(s)', $cleared)); + + // ── 2. Régénérer le cache des avatars ──────────────────────────────── + $io->section('Régénération du cache avatars (WebP)'); + + $persons = $this->personRepository->findAll(); + $warmed = 0; + $skipped = 0; + + foreach ($persons as $person) { + $picture = $person->getPicture(); + + if ($picture === null) { + ++$skipped; + continue; + } + + try { + $this->avatarCacheService->warmUp($picture); + $io->writeln(sprintf(' %s', $person->getFullName())); + ++$warmed; + } catch (\Throwable $e) { + $io->warning(sprintf('%s — %s', $person->getFullName(), $e->getMessage())); + } + } + + $io->success(sprintf( + '%d avatar(s) régénéré(s), %d sans image, %d session(s) invalidée(s).', + $warmed, + $skipped, + $cleared, + )); + + return Command::SUCCESS; + } +} diff --git a/backend/src/Command/MigratePicturesCommand.php b/backend/src/Command/MigratePicturesCommand.php deleted file mode 100644 index 6947396..0000000 --- a/backend/src/Command/MigratePicturesCommand.php +++ /dev/null @@ -1,98 +0,0 @@ -projectDir . '/public/uploads/pictures/'; - $newDir = $this->projectDir . '/public/uploads/avatars/'; - - if (!is_dir($newDir) && !mkdir($newDir, 0755, true) && !is_dir($newDir)) { - $io->error('Impossible de créer le dossier ' . $newDir); - return Command::FAILURE; - } - - $persons = $this->personRepository->findAll(); - $migrated = 0; - $skipped = 0; - $missing = 0; - - foreach ($persons as $person) { - $oldName = $person->getPicture(); - - if ($oldName === null) { - continue; - } - - // Ancien placeholder sans image → NULL - if ($oldName === 'no-picture.svg') { - $person->setPicture(null); - $this->personRepository->update($person); - $io->writeln(sprintf(' %s → NULL (no-picture.svg)', $person->getFullName())); - ++$migrated; - continue; - } - - // Déjà migré (hash SHA-256 de 40 chars) - if (preg_match('/^[0-9a-f]{40}\.[a-z]+$/', $oldName)) { - ++$skipped; - continue; - } - - $oldPath = $oldDir . $oldName; - - if (!file_exists($oldPath)) { - $io->warning('Fichier introuvable, ignoré : ' . $oldName); - ++$missing; - continue; - } - - $hash = hash_file('sha256', $oldPath); - $ext = pathinfo($oldName, PATHINFO_EXTENSION); - $newName = substr((string) $hash, 0, 40) . '.' . $ext; - $newPath = $newDir . $newName; - - if (file_exists($newPath)) { - $io->writeln(sprintf(' %s → %s (déjà présent, BDD mise à jour)', $oldName, $newName)); - } else { - if (!copy($oldPath, $newPath)) { - $io->error(sprintf('Échec de la copie : %s → %s', $oldName, $newName)); - return Command::FAILURE; - } - - $io->writeln(sprintf(' %s → %s', $oldName, $newName)); - } - - $person->setPicture($newName); - $this->personRepository->update($person); - ++$migrated; - } - - $io->success(sprintf('Migration terminée : %d migrée(s), %d déjà à jour, %d introuvable(s).', $migrated, $skipped, $missing)); - - return Command::SUCCESS; - } -} diff --git a/backend/src/Controller/Api/PersonApiController.php b/backend/src/Controller/Api/PersonApiController.php index 4ba78d1..c8df7ee 100644 --- a/backend/src/Controller/Api/PersonApiController.php +++ b/backend/src/Controller/Api/PersonApiController.php @@ -15,6 +15,7 @@ use App\Entity\Person\Role; use App\Entity\Person\User; use App\Security\Voter\PersonVoter; +use App\Service\AvatarCacheService; use App\Service\PersonService; use App\Service\UserService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -33,6 +34,7 @@ public function __construct( private readonly PersonService $personService, private readonly UserService $userService, private readonly ValidatorInterface $validator, + private readonly AvatarCacheService $avatarCacheService, ) { } @@ -48,7 +50,15 @@ public function list(Request $request): JsonResponse ]; $orderByParam = $request->query->getString('orderBy', 'id'); $orderBy = in_array($orderByParam, $allowedOrderBy, true) ? $orderByParam : 'id'; - $people = $this->personService->getAll($orderBy); + + $q = trim($request->query->getString('q', '')); + $limit = $request->query->getInt('limit', 20); + + if ($q === '') { + return ApiResponse::success([]); + } + + $people = $this->personService->getAll($orderBy, $q, $limit); /** @var PersonResponseDto[] $dtos */ $dtos = array_map( @@ -124,8 +134,8 @@ public function uploadPicture(Person $person, Request $request): JsonResponse 'image/webp', 'image/gif', ], - maxWidth: 4096, - maxHeight: 4096, + maxWidth: 2000, + maxHeight: 2000, detectCorrupted: true, ), ]); @@ -137,10 +147,20 @@ public function uploadPicture(Person $person, Request $request): JsonResponse ); } + $oldPicture = $person->getPicture(); + if ($oldPicture !== null) { + $this->avatarCacheService->bust($oldPicture); + } + $this->personService->update($person); $person->setPictureFile(null); - return ApiResponse::success(['picture' => $person->getPicture()]); + $newPicture = $person->getPicture(); + if ($newPicture !== null) { + $this->avatarCacheService->warmUp($newPicture); + } + + return ApiResponse::success(['picture' => $newPicture]); } #[Route('/api/persons/{id}/account', name: 'api_persons_get_account', methods: ['GET'])] diff --git a/backend/src/Entity/Person/Person.php b/backend/src/Entity/Person/Person.php index 2ec7a18..10b5286 100644 --- a/backend/src/Entity/Person/Person.php +++ b/backend/src/Entity/Person/Person.php @@ -52,8 +52,8 @@ class Person implements \Stringable 'image/webp', 'image/gif', ], - maxWidth: 4096, - maxHeight: 4096, + maxWidth: 2000, + maxHeight: 2000, detectCorrupted: true, )] private ?File $pictureFile = null; diff --git a/backend/src/Entity/Person/User.php b/backend/src/Entity/Person/User.php index 87f34f8..393e9a2 100644 --- a/backend/src/Entity/Person/User.php +++ b/backend/src/Entity/Person/User.php @@ -220,4 +220,30 @@ public function getCreatedAt(): ?\DateTimeInterface { return $this->createdAt; } + + /** + * @return array + */ + public function __serialize(): array + { + return [ + 'id' => $this->id, + 'email' => $this->email, + 'password' => $this->password, + 'roles' => $this->roles, + 'isValidated' => $this->isValidated, + ]; + } + + /** + * @param array{id?: int|null, email?: string|null, password?: string|null, roles?: string[], isValidated?: bool} $data + */ + public function __unserialize(array $data): void + { + $this->id = $data['id'] ?? null; + $this->email = $data['email'] ?? null; + $this->password = $data['password'] ?? null; + $this->roles = $data['roles'] ?? [Role::USER->value]; + $this->isValidated = $data['isValidated'] ?? false; + } } diff --git a/backend/src/Repository/PersonRepository.php b/backend/src/Repository/PersonRepository.php index 5a61096..72f7b70 100644 --- a/backend/src/Repository/PersonRepository.php +++ b/backend/src/Repository/PersonRepository.php @@ -23,7 +23,7 @@ public function __construct(ManagerRegistry $managerRegistry) /** * @return Person[] */ - public function getAll(string $orderBy = 'id'): array + public function getAll(string $orderBy = 'id', ?string $q = null, int $limit = 20): array { $allowedColumns = [ 'id', @@ -39,16 +39,23 @@ public function getAll(string $orderBy = 'id'): array ); } - /** @var Person[] $result */ - $result = $this->createQueryBuilder('p') + $qb = $this->createQueryBuilder('p') ->leftJoin('p.filieres', 'pf')->addSelect('pf') ->leftJoin('pf.filiere', 'f')->addSelect('f') ->leftJoin('pf.school', 's')->addSelect('s') ->leftJoin('p.associations', 'pa')->addSelect('pa') ->leftJoin('pa.association', 'assoc')->addSelect('assoc') - ->orderBy('p.' . $orderBy, 'ASC') - ->getQuery() - ->getResult(); + ->orderBy('p.' . $orderBy, 'ASC'); + + if ($q !== null && trim($q) !== '') { + $escaped = addcslashes(trim($q), '%_\\'); + $qb->andWhere('LOWER(p.firstName) LIKE LOWER(:q) OR LOWER(p.lastName) LIKE LOWER(:q)') + ->setParameter('q', '%' . $escaped . '%') + ->setMaxResults(max(1, min($limit, 100))); + } + + /** @var Person[] $result */ + $result = $qb->getQuery()->getResult(); return $result; } diff --git a/backend/src/Service/AvatarCacheService.php b/backend/src/Service/AvatarCacheService.php new file mode 100644 index 0000000..7a63996 --- /dev/null +++ b/backend/src/Service/AvatarCacheService.php @@ -0,0 +1,37 @@ +filterService->warmUpCache($path, $filter, null, true); + } + } + + public function bust(string $filename): void + { + $path = 'uploads/avatars/' . $filename; + + foreach (self::FILTERS as $filter) { + $this->filterService->bustCache($path, $filter); + } + } +} diff --git a/backend/src/Service/PersonService.php b/backend/src/Service/PersonService.php index a6add16..33be7b5 100644 --- a/backend/src/Service/PersonService.php +++ b/backend/src/Service/PersonService.php @@ -51,9 +51,9 @@ public function getWithRelations(int $id): ?Person * @param 'id'|'firstName'|'lastName'|'startYear'|'createdAt' $orderBy * @return Person[] */ - public function getAll(string $orderBy = 'id'): array + public function getAll(string $orderBy = 'id', ?string $q = null, int $limit = 20): array { - return $this->personRepository->getAll($orderBy); + return $this->personRepository->getAll($orderBy, $q, $limit); } public function findByIdentity(string $firstName, string $lastName): ?Person diff --git a/docker/Dockerfile b/docker/Dockerfile index 631106b..b02c7db 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,11 +18,13 @@ ENV SERVER_NAME=":80" RUN apk add --no-cache netcat-openbsd -RUN apk add --no-cache --virtual .build-deps libpng-dev libjpeg-turbo-dev libwebp-dev icu-dev \ +RUN apk add --no-cache --virtual .build-deps autoconf build-base libpng-dev libjpeg-turbo-dev libwebp-dev icu-dev imagemagick-dev \ && docker-php-ext-configure gd --with-jpeg --with-webp \ && docker-php-ext-install pdo_mysql gd intl \ + && pecl install imagick \ + && docker-php-ext-enable imagick \ && apk del .build-deps \ - && apk add --no-cache libpng libjpeg-turbo libwebp icu-libs + && apk add --no-cache libpng libjpeg-turbo libwebp icu-libs imagemagick WORKDIR /app diff --git a/e2e/tests/profile/sponsor-add.spec.ts b/e2e/tests/profile/sponsor-add.spec.ts index 26219fd..a6d1534 100644 --- a/e2e/tests/profile/sponsor-add.spec.ts +++ b/e2e/tests/profile/sponsor-add.spec.ts @@ -7,6 +7,78 @@ test.beforeEach(() => { clearMailpit(); }); +test.describe('person autocomplete (async SuggestInput)', () => { + test('no dropdown with fewer than 2 characters', async ({ page }) => { + await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); + const me = await getMe(page); + await page.goto(`/person/${me.person.id.toString()}/edit`); + + const autocomplete = page.getByPlaceholder('Rechercher une personne…'); + await autocomplete.fill('H'); + + // minChars = 2 : aucune liste ne doit apparaître + await expect(page.getByRole('listbox')).not.toBeVisible(); + }); + + test('shows results after debounce with 2+ characters', async ({ page }) => { + await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); + const me = await getMe(page); + await page.goto(`/person/${me.person.id.toString()}/edit`); + + const autocomplete = page.getByPlaceholder('Rechercher une personne…'); + await autocomplete.fill('Hen'); + + // Résultats visibles après debounce + requête réseau + const henriOption = page.getByRole('option').filter({ hasText: 'Henri DURAND' }).first(); + await expect(henriOption).toBeVisible({ timeout: 5_000 }); + + // La liste dropdown est bien un listbox + await expect(page.getByRole('listbox')).toBeVisible(); + }); + + test('current person is excluded from results', async ({ page }) => { + await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); + const me = await getMe(page); + await page.goto(`/person/${me.person.id.toString()}/edit`); + + // Récupère le nom complet de l'utilisateur connecté + const personResp = await page.request.get(`/api/persons/${me.person.id.toString()}`); + const personBody = (await personResp.json()) as { data: { fullName: string } }; + const myFullName = personBody.data.fullName; + + // Cherche son propre prénom dans l'autocomplete + const firstName = myFullName.split(' ')[0] ?? 'Luka'; + const autocomplete = page.getByPlaceholder('Rechercher une personne…'); + await autocomplete.fill(firstName); + + await page.waitForTimeout(400); // debounce + réseau + + // Son propre nom ne doit pas apparaître dans la liste + await expect(page.getByRole('option').filter({ hasText: myFullName })).not.toBeVisible(); + }); + + test('selecting a result resets if the user types again', async ({ page }) => { + await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); + const me = await getMe(page); + await page.goto(`/person/${me.person.id.toString()}/edit`); + + const autocomplete = page.getByPlaceholder('Rechercher une personne…'); + await autocomplete.fill('Hen'); + + const henriOption = page.getByRole('option').filter({ hasText: 'Henri DURAND' }).first(); + await expect(henriOption).toBeVisible({ timeout: 5_000 }); + await henriOption.click(); + + // L'input affiche le nom complet sélectionné + await expect(autocomplete).toHaveValue('Henri DURAND'); + + // Si on retape, la sélection est effacée (bouton Ajouter reste disabled) + await autocomplete.fill('Hen'); + const addButton = page.getByRole('button', { name: 'Ajouter', exact: true }); + await expect(addButton).toBeDisabled(); + }); +}); + test('add a HEART sponsor link from Luka to Henri', async ({ page }) => { await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); const me = await getMe(page); @@ -18,10 +90,10 @@ test('add a HEART sponsor link from Luka to Henri', async ({ page }) => { // Rôle Parrain (Luka devient parrain de quelqu'un) await page.getByRole('button', { name: 'Parrain', exact: true }).click(); - // Autocomplete : taper "Henri" → cliquer sur "Henri Durand" + // Autocomplete : taper "Henri" → attendre les résultats → cliquer sur "Henri Durand" const autocomplete = page.getByPlaceholder('Rechercher une personne…'); await autocomplete.fill('Henri'); - const henriOption = page.getByRole('listitem').filter({ hasText: 'Henri Durand' }).first(); + const henriOption = page.getByRole('option').filter({ hasText: 'Henri DURAND' }).first(); await expect(henriOption).toBeVisible({ timeout: 5_000 }); await henriOption.click(); @@ -38,7 +110,6 @@ test('add a HEART sponsor link from Luka to Henri', async ({ page }) => { await expect(page.getByText('Parrainage ajouté')).toBeVisible({ timeout: 5_000 }); // Une SponsorRow avec Henri Durand apparaît dans la section "Fillots" - // (le nom est rendu en MAJUSCULES dans la SponsorRow) await expect(page.getByText('Henri DURAND').first()).toBeVisible(); // Vérification API : Luka a maintenant Henri parmi ses godChildren diff --git a/e2e/tests/tree/family-navigation.spec.ts b/e2e/tests/tree/family-navigation.spec.ts index eb1b537..8967e89 100644 --- a/e2e/tests/tree/family-navigation.spec.ts +++ b/e2e/tests/tree/family-navigation.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test'; import { resetFixtures, clearMailpit } from '../../helpers/fixtures'; import { assertDefined } from '../../helpers/assert'; +import { LUKA_EMAIL, DEFAULT_PASSWORD, loginAs, getMe } from '../../helpers/auth'; interface SponsorLink { godFatherId: number; @@ -9,29 +10,17 @@ interface SponsorLink { godChildName: string; } -interface TreePersonLite { - id: number; - firstName: string; - lastName: string; -} - test.beforeEach(() => { resetFixtures(); clearMailpit(); }); test('family graph navigation: Luka → Lilian (parrain)', async ({ page }) => { - // On récupère l'ID de Luka et celui de son parrain Lilian via l'API persons - const treeResp = await page.request.get('/api/persons'); - expect(treeResp.ok()).toBeTruthy(); - const tree = (await treeResp.json()) as { data: TreePersonLite[] }; - const luka = assertDefined( - tree.data.find((p) => p.firstName === 'Luka'), - 'Luka person in fixtures', - ); + await loginAs(page, LUKA_EMAIL, DEFAULT_PASSWORD); + const me = await getMe(page); // Récupérer le parrain de Luka via /api/persons/{id} - const personResp = await page.request.get(`/api/persons/${luka.id.toString()}`); + const personResp = await page.request.get(`/api/persons/${me.person.id.toString()}`); expect(personResp.ok()).toBeTruthy(); const personBody = (await personResp.json()) as { data: { godFathers: SponsorLink[] } }; const lilianLink = assertDefined( @@ -41,7 +30,7 @@ test('family graph navigation: Luka → Lilian (parrain)', async ({ page }) => { const lilianId = lilianLink.godFatherId; // Aller sur la fiche Luka - await page.goto(`/person/${luka.id.toString()}`); + await page.goto(`/person/${me.person.id.toString()}`); await expect(page.getByRole('heading', { level: 1 })).toContainText(/luka\s+maret/i); // Cliquer sur le nœud Lilian dans le family graph diff --git a/frontend/src/components/ui/SuggestInput.tsx b/frontend/src/components/ui/SuggestInput.tsx index 88ca957..5666dea 100644 --- a/frontend/src/components/ui/SuggestInput.tsx +++ b/frontend/src/components/ui/SuggestInput.tsx @@ -1,18 +1,47 @@ -import { useRef, useState } from 'react'; -import type { InputHTMLAttributes, KeyboardEvent } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { InputHTMLAttributes, KeyboardEvent, ReactNode } from 'react'; import { cn } from '../../lib/cn'; -interface SuggestInputProps extends Omit< - InputHTMLAttributes, - 'value' | 'onChange' -> { +type BaseProps = Omit, 'value' | 'onChange'> & { value: string; onChange: (value: string) => void; - suggestions: string[]; wrapperClassName?: string; +}; + +type SyncProps = BaseProps & { + suggestions: string[]; + search?: never; + getLabel?: never; + getKey?: never; + renderItem?: never; + onPick?: (item: string) => void; + debounceMs?: never; + minChars?: never; +}; + +type AsyncProps = BaseProps & { + suggestions?: never; + search: (query: string) => Promise; + getLabel: (item: T) => string; + getKey: (item: T) => string | number; + renderItem?: (item: T, active: boolean) => ReactNode; + onPick: (item: T) => void; + debounceMs?: number; + minChars?: number; +}; + +export type SuggestInputProps = SyncProps | AsyncProps; + +export function SuggestInput(props: SuggestInputProps) { + if ('search' in props && typeof props.search === 'function') { + return ; + } + return ; } -export function SuggestInput({ +// ── Mode synchrone (rétrocompat : filières, écoles…) ───────────────────────── + +function SyncSuggestInput({ value, onChange, suggestions, @@ -20,8 +49,9 @@ export function SuggestInput({ className, onFocus, onBlur, - ...props -}: SuggestInputProps) { + onPick, + ...rest +}: SyncProps) { const [open, setOpen] = useState(false); const [cursor, setCursor] = useState(0); const wrapperRef = useRef(null); @@ -34,6 +64,7 @@ export function SuggestInput({ function pick(s: string) { onChange(s); + onPick?.(s); setOpen(false); setCursor(0); } @@ -78,15 +109,13 @@ export function SuggestInput({ 'h-9 w-full rounded-[9px] border border-line bg-surface px-3.5 text-sm text-ink outline-none transition-colors placeholder:text-ink-4 focus:border-ink-2', className, )} - {...props} + {...rest} /> {open && filtered.length > 0 && (
    {filtered.map((s, i) => (
  • { e.preventDefault(); pick(s); @@ -104,3 +133,147 @@ export function SuggestInput({ ); } + +// ── Mode asynchrone (recherche serveur avec debounce) ──────────────────────── + +function AsyncSuggestInput({ + value, + onChange, + search, + getLabel, + getKey, + renderItem, + onPick, + debounceMs = 200, + minChars = 2, + wrapperClassName, + className, + onFocus, + onBlur, + ...rest +}: AsyncProps) { + const [open, setOpen] = useState(false); + const [cursor, setCursor] = useState(0); + const [fetched, setFetched] = useState([]); + const [loading, setLoading] = useState(false); + const wrapperRef = useRef(null); + const requestIdRef = useRef(0); + + const trimmed = value.trim(); + const tooShort = trimmed.length < minChars; + const items = tooShort ? [] : fetched; + + useEffect(() => { + if (tooShort) return; + + const id = ++requestIdRef.current; + const timer = window.setTimeout(() => { + setLoading(true); + search(trimmed) + .then((result) => { + if (id !== requestIdRef.current) return; + setFetched(result); + setCursor(0); + }) + .catch(() => { + if (id !== requestIdRef.current) return; + setFetched([]); + }) + .finally(() => { + if (id === requestIdRef.current) setLoading(false); + }); + }, debounceMs); + + return () => { + window.clearTimeout(timer); + }; + }, [trimmed, tooShort, debounceMs, search]); + + function pick(item: T) { + onChange(getLabel(item)); + onPick(item); + setOpen(false); + setCursor(0); + } + + function handleKeyDown(e: KeyboardEvent) { + if (!open || items.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setCursor((c) => Math.min(c + 1, items.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setCursor((c) => Math.max(c - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = items[cursor]; + if (item) pick(item); + } else if (e.key === 'Escape') { + setOpen(false); + } + } + + const showDropdown = open && !tooShort; + const showEmpty = showDropdown && !loading && items.length === 0; + + return ( +
    + { + onChange(e.target.value); + setCursor(0); + setOpen(true); + }} + onFocus={(e) => { + setOpen(true); + onFocus?.(e); + }} + onBlur={(e) => { + setOpen(false); + onBlur?.(e); + }} + onKeyDown={handleKeyDown} + autoComplete="off" + className={cn( + 'h-9 w-full rounded-[9px] border border-line bg-surface px-3.5 text-sm text-ink outline-none transition-colors placeholder:text-ink-4 focus:border-ink-2', + className, + )} + {...rest} + /> + {showDropdown && (loading || items.length > 0 || showEmpty) && ( +
      + {loading && items.length === 0 && ( +
    • Recherche…
    • + )} + {!loading && showEmpty && ( +
    • Aucun résultat
    • + )} + {items.map((item, i) => { + const active = i === cursor; + return ( +
    • { + e.preventDefault(); + pick(item); + }} + className={cn( + 'cursor-pointer select-none px-3.5 py-2 text-[13px] transition-colors', + active ? 'bg-bg text-ink' : 'text-ink-2 hover:bg-bg', + )} + > + {renderItem ? renderItem(item, active) : getLabel(item)} +
    • + ); + })} +
    + )} +
    + ); +} diff --git a/frontend/src/lib/api/persons.ts b/frontend/src/lib/api/persons.ts index f729361..7f58eb6 100644 --- a/frontend/src/lib/api/persons.ts +++ b/frontend/src/lib/api/persons.ts @@ -4,8 +4,19 @@ import type { Result } from '../../types/api'; export type PersonOrderBy = 'id' | 'firstName' | 'lastName' | 'startYear' | 'createdAt'; -export function getPersons(orderBy: PersonOrderBy = 'id'): Promise> { - return get(`/api/persons?orderBy=${orderBy}`); +export interface SearchPersonsParams { + q: string; + orderBy?: PersonOrderBy; + limit?: number; +} + +export function searchPersons({ + q, + orderBy = 'lastName', + limit = 20, +}: SearchPersonsParams): Promise> { + const params = new URLSearchParams({ q, orderBy, limit: String(limit) }); + return get(`/api/persons?${params.toString()}`); } export function getPerson(id: number): Promise> { diff --git a/frontend/src/lib/queries.ts b/frontend/src/lib/queries.ts index b0a67a7..60f07db 100644 --- a/frontend/src/lib/queries.ts +++ b/frontend/src/lib/queries.ts @@ -1,6 +1,6 @@ import type { Result } from '../types/api'; import { getHomeStats } from './api/home'; -import { getAccount, getPerson, getPersons } from './api/persons'; +import { getAccount, getPerson, searchPersons } from './api/persons'; import { getSponsor } from './api/sponsors'; /** Converts a Result into a resolved T or a thrown Error. */ @@ -18,10 +18,11 @@ export const homeQueries = { }; export const personQueries = { - list: () => ({ - queryKey: ['persons'] as const, - queryFn: () => getPersons().then(throwable), - staleTime: 5 * 60 * 1000, + search: (q: string, limit = 20) => ({ + queryKey: ['persons', 'search', q, limit] as const, + queryFn: () => searchPersons({ q, limit }).then(throwable), + staleTime: 60 * 1000, + enabled: q.trim().length >= 2, }), detail: (id: number) => ({ queryKey: ['persons', id] as const, diff --git a/frontend/src/pages/person/EditPersonPage.tsx b/frontend/src/pages/person/EditPersonPage.tsx index e67b8a4..55ab739 100644 --- a/frontend/src/pages/person/EditPersonPage.tsx +++ b/frontend/src/pages/person/EditPersonPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef, useState } from 'react'; -import type { KeyboardEvent, SyntheticEvent } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { SyntheticEvent } from 'react'; import type { ReactNode } from 'react'; import { useNavigate, useParams } from 'react-router'; import { @@ -61,129 +61,32 @@ function FieldLabel({ children }: { children: ReactNode }) { ); } -// ── Autocomplete personne ───────────────────────────────────────────────────── - -function PersonAutocomplete({ - persons, - excludeId, - value, - onSelect, - placeholder, -}: { - persons: Person[]; - excludeId: number; - value: Person | null; - onSelect: (p: Person | null) => void; - placeholder?: string; -}) { - const [query, setQuery] = useState(value?.fullName ?? ''); - const [open, setOpen] = useState(false); - const [cursor, setCursor] = useState(0); - const ref = useRef(null); - - const filtered = query.trim() - ? persons - .filter((p) => p.id !== excludeId && p.fullName.toLowerCase().includes(query.toLowerCase())) - .slice(0, 8) - : []; - - const displayQuery = value ? value.fullName : query; - - useEffect(() => { - function onClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', onClickOutside); - return () => { - document.removeEventListener('mousedown', onClickOutside); - }; - }, []); - - function pick(p: Person) { - onSelect(p); - setQuery(p.fullName); - setOpen(false); - } - - function handleKeyDown(e: KeyboardEvent) { - if (!open || filtered.length === 0) return; - if (e.key === 'ArrowDown') { - e.preventDefault(); - setCursor((c) => Math.min(c + 1, filtered.length - 1)); - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setCursor((c) => Math.max(c - 1, 0)); - } - if (e.key === 'Enter') { - e.preventDefault(); - const p = filtered[cursor]; - if (p) pick(p); - } - if (e.key === 'Escape') { - setOpen(false); - } - } - - return ( -
    - { - setQuery(e.target.value); - onSelect(null); - setOpen(true); - setCursor(0); - }} - onFocus={() => { - if (displayQuery) setOpen(true); - }} - onKeyDown={handleKeyDown} - placeholder={placeholder ?? 'Rechercher une personne…'} - /> - {open && filtered.length > 0 && ( -
      - {filtered.map((p, i) => ( -
    • { - e.preventDefault(); - pick(p); - }} - className={`flex cursor-pointer select-none items-center gap-2.5 px-3 py-2 text-[13px] transition-colors ${ - i === cursor ? 'bg-bg text-ink' : 'text-ink-2 hover:bg-bg' - }`} - > - - {p.fullName} - Promo {p.startYear} -
    • - ))} -
    - )} -
    - ); -} - // ── Formulaire ajout parrainage ─────────────────────────────────────────────── function AddSponsorForm({ personId, - persons, onAdded, }: { personId: number; - persons: Person[]; onAdded: (s: Sponsor) => void; }) { const { notify } = useNotification(); const queryClient = useQueryClient(); const [role, setRole] = useState<'godChild' | 'godFather'>('godChild'); const [otherPerson, setOtherPerson] = useState(null); + const [otherQuery, setOtherQuery] = useState(''); const [type, setType] = useState('CLASSIC'); const [date, setDate] = useState(''); const [description, setDescription] = useState(''); + const searchOtherPersons = useCallback( + async (q: string): Promise => { + const data = await queryClient.fetchQuery(personQueries.search(q, 20)); + return data.filter((p) => p.id !== personId); + }, + [queryClient, personId], + ); + const { mutate, isPending: saving } = useMutation({ mutationFn: createSponsor, onSuccess: (result) => { @@ -202,6 +105,7 @@ function AddSponsorForm({ }); notify('success', 'Parrainage ajouté'); setOtherPerson(null); + setOtherQuery(''); setDate(''); setDescription(''); void queryClient.invalidateQueries({ queryKey: personQueries.detail(personId).queryKey }); @@ -262,11 +166,29 @@ function AddSponsorForm({ {/* Personne */}
    {role === 'godFather' ? 'Fillot' : 'Parrain'} - + value={otherQuery} + onChange={(v) => { + setOtherQuery(v); + if (otherPerson && v !== otherPerson.fullName) setOtherPerson(null); + }} + search={searchOtherPersons} + getLabel={(p) => p.fullName} + getKey={(p) => p.id} + renderItem={(p, active) => ( +
    + + {p.fullName} + + Promo {p.startYear} + +
    + )} + onPick={(p) => { + setOtherPerson(p); + setOtherQuery(p.fullName); + }} + placeholder="Rechercher une personne…" />
    @@ -1157,17 +1079,15 @@ export function EditPersonPage() { const personId = Number(id); const queryClient = useQueryClient(); - const [personQuery, personsQuery, accountQuery] = useQueries({ + const [personQuery, accountQuery] = useQueries({ queries: [ { ...personQueries.detail(personId), enabled: !!id }, - { ...personQueries.list(), enabled: !!id }, { ...personQueries.account(personId), enabled: !!id }, ], }); const person = personQuery.data ?? null; - const allPersons = personsQuery.data ?? []; - const loading = personQuery.isLoading || personsQuery.isLoading; + const loading = personQuery.isLoading; const initialized = useRef(false); const [saving, setSaving] = useState(false); @@ -1503,7 +1423,6 @@ export function EditPersonPage() { { setSponsors((prev) => [...prev, s]); }}