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 %}
-