diff --git a/docs/superpowers/plans/2026-03-29-user-import-notifications.md b/docs/superpowers/plans/2026-03-29-user-import-notifications.md new file mode 100644 index 0000000..f1418f7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-user-import-notifications.md @@ -0,0 +1,2063 @@ +# User Film Import, Navbar & Notifications — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow authenticated users to import their Letterboxd CSV, process it asynchronously (films + actors via TMDB), and receive notifications when done — all through a navbar with user dropdown and notification system. + +**Architecture:** Symfony Messenger handles async processing in batches of 50 films. Files are stored on SeaweedFS (S3-compatible) via Flysystem. A polling-based notification system (Stimulus controller, 30s interval) provides feedback. Three new entities: `UserMovie`, `Import`, `Notification`. Logic from existing sync commands is extracted into reusable services. + +**Tech Stack:** Symfony 8, Doctrine ORM, Symfony Messenger (Doctrine transport), Flysystem S3, Symfony Secrets, Stimulus controllers, Twig + +--- + +## File Structure + +### New Files + +**Entities & Repositories:** +- `src/Entity/UserMovie.php` — join entity User ↔ Movie +- `src/Entity/Import.php` — import job tracking +- `src/Entity/Notification.php` — user notifications +- `src/Repository/UserMovieRepository.php` +- `src/Repository/ImportRepository.php` +- `src/Repository/NotificationRepository.php` + +**Services (extracted from commands):** +- `src/Service/FilmImporter.php` — find or create a Movie from a parsed CSV row via TMDB +- `src/Service/ActorSyncer.php` — sync actors for a Movie via TMDB + +**Messenger:** +- `src/Message/ProcessImportMessage.php` +- `src/Message/ImportFilmsBatchMessage.php` +- `src/MessageHandler/ProcessImportMessageHandler.php` +- `src/MessageHandler/ImportFilmsBatchMessageHandler.php` + +**Controllers:** +- `src/Controller/ImportController.php` — `POST /api/imports` +- `src/Controller/NotificationController.php` — `GET /api/notifications`, `POST /api/notifications/read` + +**Frontend:** +- `assets/controllers/dropdown_controller.js` — generic dropdown toggle +- `assets/controllers/notifications_controller.js` — notification polling, badge, title +- `assets/controllers/import_modal_controller.js` — modal + file upload +- `templates/_navbar.html.twig` — navbar partial + +**Flysystem config:** +- `config/packages/flysystem.yaml` + +### Modified Files + +- `src/Entity/User.php` — add OneToMany relations +- `src/Command/SyncFilmsCommands.php` — refactor to use `FilmImporter` +- `src/Command/SyncActorsCommand.php` — refactor to use `ActorSyncer` +- `config/packages/messenger.yaml` — add message routing +- `templates/base.html.twig` — include navbar +- `assets/styles/app.css` — navbar, dropdown, modal, notification styles + +--- + +## Task 1: Install Flysystem S3 & Configure SeaweedFS + +**Files:** +- Modify: `composer.json` +- Create: `config/packages/flysystem.yaml` + +- [ ] **Step 1: Install Flysystem packages** + +```bash +composer require league/flysystem-bundle league/flysystem-aws-s3-v3 +``` + +- [ ] **Step 2: Generate Symfony Secrets for S3 credentials** + +```bash +php bin/console secrets:set S3_ACCESS_KEY +php bin/console secrets:set S3_SECRET_KEY +``` + +Enter the actual credentials when prompted. This creates `config/secrets/dev/` vault files. + +- [ ] **Step 3: Create Flysystem config** + +Create `config/packages/flysystem.yaml`: + +```yaml +flysystem: + storages: + default.storage: + adapter: 'aws' + options: + client: 's3_client' + bucket: 'ltbxd-actorle' + +services: + s3_client: + class: Aws\S3\S3Client + arguments: + - endpoint: 'https://s3.lclr.dev' + credentials: + key: '%env(secret:S3_ACCESS_KEY)%' + secret: '%env(secret:S3_SECRET_KEY)%' + region: 'us-east-1' + version: 'latest' + use_path_style_endpoint: true +``` + +Note: `use_path_style_endpoint: true` is needed for SeaweedFS (not a real AWS endpoint). The `region` is required by the S3 client but ignored by SeaweedFS. + +- [ ] **Step 4: Verify Flysystem is wired** + +```bash +php bin/console debug:container default.storage +``` + +Expected: shows the Flysystem storage service. + +- [ ] **Step 5: Commit** + +```bash +git add composer.json composer.lock symfony.lock config/packages/flysystem.yaml config/secrets/ +git commit -m "feat: add Flysystem S3 storage for SeaweedFS" +``` + +--- + +## Task 2: Create UserMovie Entity + +**Files:** +- Create: `src/Entity/UserMovie.php` +- Create: `src/Repository/UserMovieRepository.php` +- Modify: `src/Entity/User.php` + +- [ ] **Step 1: Create the UserMovie entity** + +Create `src/Entity/UserMovie.php`: + +```php +id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getMovie(): ?Movie + { + return $this->movie; + } + + public function setMovie(?Movie $movie): static + { + $this->movie = $movie; + + return $this; + } +} +``` + +- [ ] **Step 2: Create the repository** + +Create `src/Repository/UserMovieRepository.php`: + +```php + + */ +class UserMovieRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserMovie::class); + } +} +``` + +- [ ] **Step 3: Generate and run migration** + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Verify the table** + +```bash +php bin/console doctrine:schema:validate +``` + +Expected: no errors about the `user_movie` table. + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/UserMovie.php src/Repository/UserMovieRepository.php migrations/ +git commit -m "feat: add UserMovie join entity" +``` + +--- + +## Task 3: Create Import Entity + +**Files:** +- Create: `src/Entity/Import.php` +- Create: `src/Repository/ImportRepository.php` + +- [ ] **Step 1: Create the Import entity** + +Create `src/Entity/Import.php`: + +```php +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getFilePath(): ?string + { + return $this->filePath; + } + + public function setFilePath(string $filePath): static + { + $this->filePath = $filePath; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getTotalBatches(): int + { + return $this->totalBatches; + } + + public function setTotalBatches(int $totalBatches): static + { + $this->totalBatches = $totalBatches; + + return $this; + } + + public function getProcessedBatches(): int + { + return $this->processedBatches; + } + + public function setProcessedBatches(int $processedBatches): static + { + $this->processedBatches = $processedBatches; + + return $this; + } + + public function getTotalFilms(): int + { + return $this->totalFilms; + } + + public function setTotalFilms(int $totalFilms): static + { + $this->totalFilms = $totalFilms; + + return $this; + } + + public function getFailedFilms(): int + { + return $this->failedFilms; + } + + public function setFailedFilms(int $failedFilms): static + { + $this->failedFilms = $failedFilms; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } + + public function setCompletedAt(?\DateTimeImmutable $completedAt): static + { + $this->completedAt = $completedAt; + + return $this; + } +} +``` + +- [ ] **Step 2: Create the repository** + +Create `src/Repository/ImportRepository.php`: + +```php + + */ +class ImportRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Import::class); + } + + public function incrementProcessedBatches(Import $import): int + { + $this->getEntityManager()->getConnection()->executeStatement( + 'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id', + ['id' => $import->getId()] + ); + + return (int) $this->getEntityManager()->getConnection()->fetchOne( + 'SELECT processed_batches FROM import WHERE id = :id', + ['id' => $import->getId()] + ); + } + + public function incrementFailedFilms(Import $import): void + { + $this->getEntityManager()->getConnection()->executeStatement( + 'UPDATE import SET failed_films = failed_films + 1 WHERE id = :id', + ['id' => $import->getId()] + ); + } +} +``` + +- [ ] **Step 3: Generate and run migration** + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/Import.php src/Repository/ImportRepository.php migrations/ +git commit -m "feat: add Import entity with batch tracking" +``` + +--- + +## Task 4: Create Notification Entity + +**Files:** +- Create: `src/Entity/Notification.php` +- Create: `src/Repository/NotificationRepository.php` + +- [ ] **Step 1: Create the Notification entity** + +Create `src/Entity/Notification.php`: + +```php +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function isRead(): bool + { + return $this->read; + } + + public function setRead(bool $read): static + { + $this->read = $read; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} +``` + +- [ ] **Step 2: Create the repository** + +Create `src/Repository/NotificationRepository.php`: + +```php + + */ +class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + /** + * @return Notification[] + */ + public function findRecentForUser(User $user, int $limit = 20): array + { + return $this->createQueryBuilder('n') + ->andWhere('n.user = :user') + ->setParameter('user', $user) + ->orderBy('n.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + public function countUnreadForUser(User $user): int + { + return $this->count(['user' => $user, 'read' => false]); + } + + public function markAllReadForUser(User $user): void + { + $this->createQueryBuilder('n') + ->update() + ->set('n.read', ':true') + ->where('n.user = :user') + ->andWhere('n.read = :false') + ->setParameter('true', true) + ->setParameter('false', false) + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } +} +``` + +- [ ] **Step 3: Generate and run migration** + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/ +git commit -m "feat: add Notification entity" +``` + +--- + +## Task 5: Extract FilmImporter Service + +**Files:** +- Create: `src/Service/FilmImporter.php` +- Modify: `src/Command/SyncFilmsCommands.php` + +- [ ] **Step 1: Create FilmImporter service** + +Create `src/Service/FilmImporter.php`: + +```php +em->getRepository(Movie::class)->findOneBy(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()]); + if ($existing) { + return $existing; + } + + $tmdbMovie = $this->tmdbGateway->searchMovie($ltbxdMovie->getName()); + if (!$tmdbMovie) { + return null; + } + + $movie = new Movie() + ->setLtbxdRef($ltbxdMovie->getLtbxdRef()) + ->setTitle($ltbxdMovie->getName()) + ->setTmdbId($tmdbMovie->getId()); + + $this->em->persist($movie); + + return $movie; + } +} +``` + +- [ ] **Step 2: Refactor SyncFilmsCommands to use FilmImporter** + +Replace the content of `src/Command/SyncFilmsCommands.php`: + +```php +ltbxdGateway->parseFile(); + } catch (GatewayException $e) { + $output->writeln('/!\ '.$e->getMessage()); + + return Command::FAILURE; + } + + $i = 0; + foreach ($ltbxdMovies as $ltbxdMovie) { + try { + $movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie); + if ($movie) { + $output->writeln('* Found '.$ltbxdMovie->getName()); + } + } catch (GatewayException $e) { + $output->writeln('/!\ '.$e->getMessage()); + + return Command::FAILURE; + } + + ++$i; + if (0 === $i % 50) { + $this->em->flush(); + } + } + + $this->em->flush(); + + $output->writeln('Films synced'); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 3: Verify the command still works** + +```bash +php bin/console app:sync-films --help +``` + +Expected: command is recognized and shows its help. + +- [ ] **Step 4: Commit** + +```bash +git add src/Service/FilmImporter.php src/Command/SyncFilmsCommands.php +git commit -m "refactor: extract FilmImporter service from SyncFilmsCommands" +``` + +--- + +## Task 6: Extract ActorSyncer Service + +**Files:** +- Create: `src/Service/ActorSyncer.php` +- Modify: `src/Command/SyncActorsCommand.php` + +- [ ] **Step 1: Create ActorSyncer service** + +Create `src/Service/ActorSyncer.php`: + +```php +tmdbGateway->getMovieCredits($movie->getTmdbId()); + + foreach ($creditsContext->cast as $actorModel) { + $actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]); + if (!$actor instanceof Actor) { + $actor = new Actor() + ->setPopularity($actorModel->popularity) + ->setName($actorModel->name) + ->setTmdbId($actorModel->id); + + $this->em->persist($actor); + } + + $existingRole = $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $movie]); + if (0 === $existingRole) { + $role = new MovieRole() + ->setMovie($movie) + ->setActor($actor) + ->setCharacter($actorModel->character); + + $this->em->persist($role); + } + } + } +} +``` + +Note: the original `SyncActorsCommand` had a bug where it only created roles when `count > 0` (i.e., when they already exist). The extracted service fixes this by creating roles when `count === 0`. + +- [ ] **Step 2: Refactor SyncActorsCommand to use ActorSyncer** + +Replace the content of `src/Command/SyncActorsCommand.php`: + +```php +em->getRepository(Movie::class)->findAll() as $film) { + try { + $output->writeln('Syncing cast for '.$film->getTitle()); + $this->actorSyncer->syncActorsForMovie($film); + } catch (GatewayException $e) { + $output->writeln('/!\ '.$e->getMessage()); + continue; + } + + $this->em->flush(); + } + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 3: Verify the command still works** + +```bash +php bin/console app:sync-actors --help +``` + +Expected: command is recognized and shows its help. + +- [ ] **Step 4: Commit** + +```bash +git add src/Service/ActorSyncer.php src/Command/SyncActorsCommand.php +git commit -m "refactor: extract ActorSyncer service from SyncActorsCommand" +``` + +--- + +## Task 7: Create Messenger Messages + +**Files:** +- Create: `src/Message/ProcessImportMessage.php` +- Create: `src/Message/ImportFilmsBatchMessage.php` +- Modify: `config/packages/messenger.yaml` + +- [ ] **Step 1: Create ProcessImportMessage** + +Create `src/Message/ProcessImportMessage.php`: + +```php +em->getRepository(Import::class)->find($message->importId); + if (!$import) { + $this->logger->error('Import not found', ['importId' => $message->importId]); + + return; + } + + try { + $csvContent = $this->defaultStorage->read($import->getFilePath()); + + $tmpFile = tempnam(sys_get_temp_dir(), 'import_'); + file_put_contents($tmpFile, $csvContent); + + try { + $ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile); + } finally { + unlink($tmpFile); + } + + $totalFilms = count($ltbxdMovies); + $totalBatches = (int) ceil($totalFilms / self::BATCH_SIZE); + + $import->setTotalFilms($totalFilms); + $import->setTotalBatches($totalBatches); + $import->setStatus(Import::STATUS_PROCESSING); + $this->em->flush(); + + for ($i = 0; $i < $totalBatches; $i++) { + $this->bus->dispatch(new ImportFilmsBatchMessage( + importId: $import->getId(), + offset: $i * self::BATCH_SIZE, + limit: self::BATCH_SIZE, + )); + } + } catch (\Throwable $e) { + $this->logger->error('Import processing failed', [ + 'importId' => $import->getId(), + 'error' => $e->getMessage(), + ]); + + $import->setStatus(Import::STATUS_FAILED); + $this->em->flush(); + + $notification = new Notification(); + $notification->setUser($import->getUser()); + $notification->setMessage('L\'import a échoué.'); + $this->em->persist($notification); + $this->em->flush(); + } + } +} +``` + +- [ ] **Step 2: Add `parseFileFromPath` method to LtbxdGateway** + +The existing `parseFile()` uses a hardcoded path from config. We need a variant that accepts a path argument. In `src/Gateway/LtbxdGateway.php`, add this method: + +```php +/** + * @return LtbxdMovie[] + * @throws GatewayException + */ +public function parseFileFromPath(string $path): array +{ + if (!file_exists($path)) { + throw new GatewayException(sprintf('Could not find file %s', $path)); + } + + $fileContent = file_get_contents($path); + + try { + return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv'); + } catch (ExceptionInterface $e) { + throw new GatewayException('Error while deserializing Letterboxd data', previous: $e); + } +} +``` + +Then refactor the existing `parseFile()` to delegate: + +```php +public function parseFile(): array +{ + return $this->parseFileFromPath($this->fileDir); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/MessageHandler/ProcessImportMessageHandler.php src/Gateway/LtbxdGateway.php +git commit -m "feat: add ProcessImportMessageHandler" +``` + +--- + +## Task 9: Create ImportFilmsBatchMessageHandler + +**Files:** +- Create: `src/MessageHandler/ImportFilmsBatchMessageHandler.php` + +- [ ] **Step 1: Create the handler** + +Create `src/MessageHandler/ImportFilmsBatchMessageHandler.php`: + +```php +em->getRepository(Import::class)->find($message->importId); + if (!$import) { + $this->logger->error('Import not found', ['importId' => $message->importId]); + + return; + } + + $csvContent = $this->defaultStorage->read($import->getFilePath()); + $tmpFile = tempnam(sys_get_temp_dir(), 'import_'); + file_put_contents($tmpFile, $csvContent); + + try { + $ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile); + } finally { + unlink($tmpFile); + } + + $batch = array_slice($ltbxdMovies, $message->offset, $message->limit); + $user = $import->getUser(); + + foreach ($batch as $ltbxdMovie) { + try { + $movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie); + if (!$movie) { + $this->importRepository->incrementFailedFilms($import); + continue; + } + + $this->actorSyncer->syncActorsForMovie($movie); + + $existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([ + 'user' => $user, + 'movie' => $movie, + ]); + if (!$existingLink) { + $userMovie = new UserMovie(); + $userMovie->setUser($user); + $userMovie->setMovie($movie); + $this->em->persist($userMovie); + } + + $this->em->flush(); + } catch (\Throwable $e) { + $this->logger->warning('Failed to import film', [ + 'film' => $ltbxdMovie->getName(), + 'importId' => $import->getId(), + 'error' => $e->getMessage(), + ]); + $this->importRepository->incrementFailedFilms($import); + } + } + + $processedBatches = $this->importRepository->incrementProcessedBatches($import); + + if ($processedBatches >= $import->getTotalBatches()) { + // Refresh the entity to get updated failedFilms from DB + $this->em->refresh($import); + + $import->setStatus(Import::STATUS_COMPLETED); + $import->setCompletedAt(new \DateTimeImmutable()); + $this->em->flush(); + + $imported = $import->getTotalFilms() - $import->getFailedFilms(); + $notification = new Notification(); + $notification->setUser($user); + $notification->setMessage(sprintf( + 'Import terminé : %d/%d films importés.', + $imported, + $import->getTotalFilms() + )); + $this->em->persist($notification); + $this->em->flush(); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/MessageHandler/ImportFilmsBatchMessageHandler.php +git commit -m "feat: add ImportFilmsBatchMessageHandler" +``` + +--- + +## Task 10: Create Import API Controller + +**Files:** +- Create: `src/Controller/ImportController.php` + +- [ ] **Step 1: Create the controller** + +Create `src/Controller/ImportController.php`: + +```php +files->get('file'); + + if (!$file) { + return $this->json(['error' => 'No file provided.'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ('csv' !== $file->getClientOriginalExtension()) { + return $this->json(['error' => 'Only CSV files are accepted.'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ($file->getSize() > 5 * 1024 * 1024) { + return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** @var User $user */ + $user = $this->getUser(); + + $import = new Import(); + $import->setUser($user); + + $em->persist($import); + $em->flush(); + + $filePath = sprintf('imports/%d/%d.csv', $user->getId(), $import->getId()); + $defaultStorage->write($filePath, file_get_contents($file->getPathname())); + + $import->setFilePath($filePath); + $em->flush(); + + $bus->dispatch(new ProcessImportMessage($import->getId())); + + return $this->json([ + 'id' => $import->getId(), + 'status' => $import->getStatus(), + ], Response::HTTP_CREATED); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Controller/ImportController.php +git commit -m "feat: add POST /api/imports endpoint" +``` + +--- + +## Task 11: Create Notification API Controller + +**Files:** +- Create: `src/Controller/NotificationController.php` + +- [ ] **Step 1: Create the controller** + +Create `src/Controller/NotificationController.php`: + +```php +getUser(); + + $notifications = $notificationRepository->findRecentForUser($user); + $unreadCount = $notificationRepository->countUnreadForUser($user); + + return $this->json([ + 'unreadCount' => $unreadCount, + 'notifications' => array_map(fn ($n) => [ + 'id' => $n->getId(), + 'message' => $n->getMessage(), + 'read' => $n->isRead(), + 'createdAt' => $n->getCreatedAt()->format('c'), + ], $notifications), + ]); + } + + #[Route('/api/notifications/read', methods: ['POST'])] + #[IsGranted('ROLE_USER')] + public function markRead(NotificationRepository $notificationRepository): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + + $notificationRepository->markAllReadForUser($user); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Controller/NotificationController.php +git commit -m "feat: add notification API endpoints" +``` + +--- + +## Task 12: Create Navbar Template + +**Files:** +- Create: `templates/_navbar.html.twig` +- Modify: `templates/base.html.twig` + +- [ ] **Step 1: Create the navbar partial** + +Create `templates/_navbar.html.twig`: + +```twig +{% if app.user %} + + +{# Import Modal #} + +{% endif %} +``` + +- [ ] **Step 2: Include navbar in base.html.twig** + +Modify `templates/base.html.twig` to include the navbar just inside ``, before the body block: + +```html + + {% include '_navbar.html.twig' %} + {% block body %}{% endblock %} + +``` + +- [ ] **Step 3: Commit** + +```bash +git add templates/_navbar.html.twig templates/base.html.twig +git commit -m "feat: add navbar with user dropdown and import modal" +``` + +--- + +## Task 13: Create Stimulus Dropdown Controller + +**Files:** +- Create: `assets/controllers/dropdown_controller.js` + +- [ ] **Step 1: Create the controller** + +Create `assets/controllers/dropdown_controller.js`: + +```javascript +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['menu', 'trigger']; + + connect() { + this._closeOnClickOutside = this._closeOnClickOutside.bind(this); + document.addEventListener('click', this._closeOnClickOutside); + } + + disconnect() { + document.removeEventListener('click', this._closeOnClickOutside); + } + + toggle() { + this.menuTarget.hidden = !this.menuTarget.hidden; + } + + _closeOnClickOutside(event) { + if (!this.element.contains(event.target)) { + this.menuTarget.hidden = true; + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add assets/controllers/dropdown_controller.js +git commit -m "feat: add Stimulus dropdown controller" +``` + +--- + +## Task 14: Create Stimulus Notifications Controller + +**Files:** +- Create: `assets/controllers/notifications_controller.js` + +- [ ] **Step 1: Create the controller** + +Create `assets/controllers/notifications_controller.js`: + +```javascript +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['badge', 'list']; + + connect() { + this._poll(); + this._interval = setInterval(() => this._poll(), 30000); + } + + disconnect() { + clearInterval(this._interval); + } + + async _poll() { + try { + const response = await fetch('/api/notifications'); + if (!response.ok) return; + + const data = await response.json(); + this._updateBadge(data.unreadCount); + this._updateTitle(data.unreadCount); + this._updateList(data.notifications); + } catch (e) { + // silently ignore polling errors + } + } + + _updateBadge(count) { + if (count > 0) { + this.badgeTarget.textContent = count; + this.badgeTarget.hidden = false; + } else { + this.badgeTarget.hidden = true; + } + } + + _updateTitle(count) { + const base = 'Actorle'; + document.title = count > 0 ? `(${count}) ${base}` : base; + } + + _updateList(notifications) { + if (notifications.length === 0) { + this.listTarget.innerHTML = ''; + return; + } + + this.listTarget.innerHTML = notifications.map(n => ` +
+

${this._escapeHtml(n.message)}

+ +
+ `).join(''); + } + + async markRead() { + await fetch('/api/notifications/read', { method: 'POST' }); + this._poll(); + } + + _escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + _formatDate(isoString) { + const date = new Date(isoString); + return date.toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); + } +} +``` + +- [ ] **Step 2: Wire the markRead action to the notifications dropdown** + +In `templates/_navbar.html.twig`, update the notifications dropdown trigger to also call markRead when the dropdown opens. Replace the notification button line: + +```twig +