From 6a844542ad62798dc84999b9ebda7dd580766b0f Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 31 Mar 2026 21:34:05 +0200 Subject: [PATCH] feat: replace notifications with import status in profile dropdown Remove the notification system entirely and show import progress directly in the user dropdown menu. Block new imports while one is already running. Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/bootstrap.js | 4 +- assets/controllers/import_modal_controller.js | 1 + .../controllers/import_status_controller.js | 99 +++++++++++++++++++ .../controllers/notifications_controller.js | 77 --------------- assets/styles/app.css | 56 ++++++----- migrations/Version20260331000001.php | 35 +++++++ src/Controller/ImportController.php | 35 ++++++- src/Controller/NotificationController.php | 49 --------- src/Entity/Notification.php | 78 --------------- src/Repository/ImportRepository.php | 23 +++++ src/Repository/NotificationRepository.php | 54 ---------- templates/_navbar.html.twig | 28 ++---- 12 files changed, 233 insertions(+), 306 deletions(-) create mode 100644 assets/controllers/import_status_controller.js delete mode 100644 assets/controllers/notifications_controller.js create mode 100644 migrations/Version20260331000001.php delete mode 100644 src/Controller/NotificationController.php delete mode 100644 src/Entity/Notification.php delete mode 100644 src/Repository/NotificationRepository.php diff --git a/assets/bootstrap.js b/assets/bootstrap.js index c8354e5..0916546 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,12 +1,12 @@ import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers'; import DropdownController from './controllers/dropdown_controller.js'; -import NotificationsController from './controllers/notifications_controller.js'; import ImportModalController from './controllers/import_modal_controller.js'; +import ImportStatusController from './controllers/import_status_controller.js'; const app = startStimulusApp(); app.register('dropdown', DropdownController); -app.register('notifications', NotificationsController); app.register('import-modal', ImportModalController); +app.register('import-status', ImportStatusController); // Register React components for {{ react_component() }} Twig function. // We register them manually because @symfony/ux-react's registerReactControllerComponents diff --git a/assets/controllers/import_modal_controller.js b/assets/controllers/import_modal_controller.js index 21e180d..296b162 100644 --- a/assets/controllers/import_modal_controller.js +++ b/assets/controllers/import_modal_controller.js @@ -43,6 +43,7 @@ export default class extends Controller { } this._showFeedback('Import lancé !', false); + document.dispatchEvent(new CustomEvent('import:started')); setTimeout(() => this.close(), 1500); } catch (e) { this._showFeedback('Une erreur est survenue.', true); diff --git a/assets/controllers/import_status_controller.js b/assets/controllers/import_status_controller.js new file mode 100644 index 0000000..a6c8a2e --- /dev/null +++ b/assets/controllers/import_status_controller.js @@ -0,0 +1,99 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['item', 'importBtn', 'badge']; + + connect() { + this._poll(); + this._interval = setInterval(() => this._poll(), 5000); + this._onImportStarted = () => this._poll(); + document.addEventListener('import:started', this._onImportStarted); + } + + disconnect() { + clearInterval(this._interval); + document.removeEventListener('import:started', this._onImportStarted); + } + + async _poll() { + try { + const response = await fetch('/api/imports/latest'); + if (!response.ok) return; + + const data = await response.json(); + this._update(data); + } catch (e) { + // silently ignore + } + } + + _update(data) { + if (!data) { + this._showDefault(); + return; + } + + const isActive = data.status === 'pending' || data.status === 'processing'; + + if (isActive) { + this._showActive(data); + } else if (data.status === 'completed') { + this._showCompleted(data); + } else if (data.status === 'failed') { + this._showFailed(); + } else { + this._showDefault(); + } + } + + _showDefault() { + this.importBtnTarget.disabled = false; + this.importBtnTarget.textContent = 'Importer ses films'; + this._removeStatus(); + this.badgeTarget.hidden = true; + } + + _showActive(data) { + this.importBtnTarget.disabled = true; + this.importBtnTarget.textContent = 'Import en cours\u2026'; + + const progress = data.totalBatches > 0 + ? Math.round((data.processedBatches / data.totalBatches) * 100) + : 0; + + this._setStatus(`${progress}% — ${data.totalFilms} films`, 'active'); + this.badgeTarget.hidden = true; + } + + _showCompleted(data) { + this.importBtnTarget.disabled = false; + this.importBtnTarget.textContent = 'Importer ses films'; + + const imported = data.totalFilms - data.failedFilms; + this._setStatus(`Dernier import : ${imported}/${data.totalFilms} films`, 'completed'); + this.badgeTarget.hidden = true; + } + + _showFailed() { + this.importBtnTarget.disabled = false; + this.importBtnTarget.textContent = 'Importer ses films'; + this._setStatus('Dernier import : échoué', 'failed'); + this.badgeTarget.hidden = true; + } + + _setStatus(text, type) { + let statusEl = this.itemTarget.querySelector('.import-status-text'); + if (!statusEl) { + statusEl = document.createElement('span'); + statusEl.className = 'import-status-text'; + this.itemTarget.appendChild(statusEl); + } + statusEl.textContent = text; + statusEl.className = `import-status-text import-status-${type}`; + } + + _removeStatus() { + const statusEl = this.itemTarget.querySelector('.import-status-text'); + if (statusEl) statusEl.remove(); + } +} diff --git a/assets/controllers/notifications_controller.js b/assets/controllers/notifications_controller.js deleted file mode 100644 index cfb31a3..0000000 --- a/assets/controllers/notifications_controller.js +++ /dev/null @@ -1,77 +0,0 @@ -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', - }); - } -} diff --git a/assets/styles/app.css b/assets/styles/app.css index 503e2b7..bccca44 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -363,35 +363,31 @@ body { margin: 0; } -/* ── Notifications ── */ +/* ── Import status ── */ -.notifications-list { - max-height: 300px; - overflow-y: auto; +.import-status-item { + display: flex; + flex-direction: column; } -.notification-item { - padding: 10px 16px; - border-bottom: 1px solid var(--surface-warm); -} - -.notification-item:last-child { - border-bottom: none; -} - -.notification-item p { - margin: 0 0 3px; - font-size: 13px; - color: var(--text); -} - -.notification-item time { +.import-status-text { + display: block; + padding: 0 12px 8px; font-size: 11px; - color: var(--text-faint); + font-weight: 500; + line-height: 1.3; } -.notification-unread { - background: var(--surface-warm); +.import-status-active { + color: var(--orange); +} + +.import-status-completed { + color: #16a34a; +} + +.import-status-failed { + color: #dc2626; } /* ── Modal ── */ @@ -595,6 +591,20 @@ body { font-size: 16px; } +.start-loader { + display: none; + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.15); + border-top-color: #ff6b81; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + /* ── Game footer ── */ .game-footer { diff --git a/migrations/Version20260331000001.php b/migrations/Version20260331000001.php new file mode 100644 index 0000000..ee55e2a --- /dev/null +++ b/migrations/Version20260331000001.php @@ -0,0 +1,35 @@ +addSql('DROP TABLE IF EXISTS notification'); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE notification ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES "user"(id), + message VARCHAR(255) NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL + ) + SQL); + $this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\''); + } +} diff --git a/src/Controller/ImportController.php b/src/Controller/ImportController.php index 2f2db94..0fa03bb 100644 --- a/src/Controller/ImportController.php +++ b/src/Controller/ImportController.php @@ -7,6 +7,7 @@ namespace App\Controller; use App\Entity\Import; use App\Entity\User; use App\Message\ProcessImportMessage; +use App\Repository\ImportRepository; use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\FilesystemOperator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -19,14 +20,45 @@ use Symfony\Component\Security\Http\Attribute\IsGranted; class ImportController extends AbstractController { + #[Route('/api/imports/latest', methods: ['GET'])] + #[IsGranted('ROLE_USER')] + public function latest(ImportRepository $importRepository): JsonResponse + { + /** @var User $user */ + $user = $this->getUser(); + + $import = $importRepository->findLatestForUser($user); + + if (!$import) { + return $this->json(null); + } + + return $this->json([ + 'id' => $import->getId(), + 'status' => $import->getStatus(), + 'totalFilms' => $import->getTotalFilms(), + 'failedFilms' => $import->getFailedFilms(), + 'processedBatches' => $import->getProcessedBatches(), + 'totalBatches' => $import->getTotalBatches(), + ]); + } + #[Route('/api/imports', methods: ['POST'])] #[IsGranted('ROLE_USER')] public function create( Request $request, FilesystemOperator $defaultStorage, EntityManagerInterface $em, + ImportRepository $importRepository, MessageBusInterface $bus, ): JsonResponse { + /** @var User $user */ + $user = $this->getUser(); + + if ($importRepository->hasActiveImport($user)) { + return $this->json(['error' => 'Un import est déjà en cours.'], Response::HTTP_CONFLICT); + } + $file = $request->files->get('file'); if (!$file) { @@ -41,9 +73,6 @@ class ImportController extends AbstractController 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); $import->setFilePath('pending'); diff --git a/src/Controller/NotificationController.php b/src/Controller/NotificationController.php deleted file mode 100644 index 9843575..0000000 --- a/src/Controller/NotificationController.php +++ /dev/null @@ -1,49 +0,0 @@ -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): Response - { - /** @var User $user */ - $user = $this->getUser(); - - $notificationRepository->markAllReadForUser($user); - - return new Response('', Response::HTTP_NO_CONTENT); - } -} diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php deleted file mode 100644 index fa2503c..0000000 --- a/src/Entity/Notification.php +++ /dev/null @@ -1,78 +0,0 @@ -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; - } -} diff --git a/src/Repository/ImportRepository.php b/src/Repository/ImportRepository.php index a5529f8..fe94ecb 100644 --- a/src/Repository/ImportRepository.php +++ b/src/Repository/ImportRepository.php @@ -33,4 +33,27 @@ class ImportRepository extends ServiceEntityRepository ['id' => $import->getId()] ); } + + public function findLatestForUser(\App\Entity\User $user): ?Import + { + return $this->createQueryBuilder('i') + ->andWhere('i.user = :user') + ->setParameter('user', $user) + ->orderBy('i.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function hasActiveImport(\App\Entity\User $user): bool + { + return (int) $this->createQueryBuilder('i') + ->select('COUNT(i.id)') + ->andWhere('i.user = :user') + ->andWhere('i.status IN (:statuses)') + ->setParameter('user', $user) + ->setParameter('statuses', [Import::STATUS_PENDING, Import::STATUS_PROCESSING]) + ->getQuery() + ->getSingleScalarResult() > 0; + } } diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php deleted file mode 100644 index ccc1e66..0000000 --- a/src/Repository/NotificationRepository.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ -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(); - } -} diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index c3be48b..4b88a10 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -1,6 +1,6 @@ {% if app.user %}
-