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 = '
Aucune notification
';
- 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 %}
-