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) <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere
2026-03-31 21:34:05 +02:00
parent 3edde1c7db
commit 6a844542ad
12 changed files with 233 additions and 306 deletions

4
assets/bootstrap.js vendored
View File

@@ -1,12 +1,12 @@
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers'; import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
import DropdownController from './controllers/dropdown_controller.js'; import DropdownController from './controllers/dropdown_controller.js';
import NotificationsController from './controllers/notifications_controller.js';
import ImportModalController from './controllers/import_modal_controller.js'; import ImportModalController from './controllers/import_modal_controller.js';
import ImportStatusController from './controllers/import_status_controller.js';
const app = startStimulusApp(); const app = startStimulusApp();
app.register('dropdown', DropdownController); app.register('dropdown', DropdownController);
app.register('notifications', NotificationsController);
app.register('import-modal', ImportModalController); app.register('import-modal', ImportModalController);
app.register('import-status', ImportStatusController);
// Register React components for {{ react_component() }} Twig function. // Register React components for {{ react_component() }} Twig function.
// We register them manually because @symfony/ux-react's registerReactControllerComponents // We register them manually because @symfony/ux-react's registerReactControllerComponents

View File

@@ -43,6 +43,7 @@ export default class extends Controller {
} }
this._showFeedback('Import lancé !', false); this._showFeedback('Import lancé !', false);
document.dispatchEvent(new CustomEvent('import:started'));
setTimeout(() => this.close(), 1500); setTimeout(() => this.close(), 1500);
} catch (e) { } catch (e) {
this._showFeedback('Une erreur est survenue.', true); this._showFeedback('Une erreur est survenue.', true);

View File

@@ -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();
}
}

View File

@@ -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 = '<p class="dropdown-empty">Aucune notification</p>';
return;
}
this.listTarget.innerHTML = notifications.map(n => `
<div class="notification-item ${n.read ? '' : 'notification-unread'}">
<p>${this._escapeHtml(n.message)}</p>
<time>${this._formatDate(n.createdAt)}</time>
</div>
`).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',
});
}
}

View File

@@ -363,35 +363,31 @@ body {
margin: 0; margin: 0;
} }
/* ── Notifications ── */ /* ── Import status ── */
.notifications-list { .import-status-item {
max-height: 300px; display: flex;
overflow-y: auto; flex-direction: column;
} }
.notification-item { .import-status-text {
padding: 10px 16px; display: block;
border-bottom: 1px solid var(--surface-warm); padding: 0 12px 8px;
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item p {
margin: 0 0 3px;
font-size: 13px;
color: var(--text);
}
.notification-item time {
font-size: 11px; font-size: 11px;
color: var(--text-faint); font-weight: 500;
line-height: 1.3;
} }
.notification-unread { .import-status-active {
background: var(--surface-warm); color: var(--orange);
}
.import-status-completed {
color: #16a34a;
}
.import-status-failed {
color: #dc2626;
} }
/* ── Modal ── */ /* ── Modal ── */
@@ -595,6 +591,20 @@ body {
font-size: 16px; 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 ── */
.game-footer { .game-footer {

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop notification table';
}
public function up(Schema $schema): void
{
$this->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)\'');
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\User; use App\Entity\User;
use App\Message\ProcessImportMessage; use App\Message\ProcessImportMessage;
use App\Repository\ImportRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -19,14 +20,45 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class ImportController extends AbstractController 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'])] #[Route('/api/imports', methods: ['POST'])]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function create( public function create(
Request $request, Request $request,
FilesystemOperator $defaultStorage, FilesystemOperator $defaultStorage,
EntityManagerInterface $em, EntityManagerInterface $em,
ImportRepository $importRepository,
MessageBusInterface $bus, MessageBusInterface $bus,
): JsonResponse { ): 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'); $file = $request->files->get('file');
if (!$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); return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
} }
/** @var User $user */
$user = $this->getUser();
$import = new Import(); $import = new Import();
$import->setUser($user); $import->setUser($user);
$import->setFilePath('pending'); $import->setFilePath('pending');

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationController extends AbstractController
{
#[Route('/api/notifications', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function index(NotificationRepository $notificationRepository): JsonResponse
{
/** @var User $user */
$user = $this->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);
}
}

View File

@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NotificationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $message = null;
#[ORM\Column(name: 'is_read')]
private bool $read = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->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;
}
}

View File

@@ -33,4 +33,27 @@ class ImportRepository extends ServiceEntityRepository
['id' => $import->getId()] ['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;
}
} }

View File

@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
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();
}
}

View File

@@ -1,6 +1,6 @@
{% if app.user %} {% if app.user %}
<div data-controller="import-modal"> <div data-controller="import-modal">
<nav class="navbar" data-controller="notifications"> <nav class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a> <a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
</div> </div>
@@ -12,33 +12,21 @@
</svg> </svg>
</a> </a>
{# Notifications #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="badge" data-notifications-target="badge" hidden></span>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<div class="dropdown-header">Notifications</div>
<div data-notifications-target="list" class="notifications-list">
<p class="dropdown-empty">Aucune notification</p>
</div>
</div>
</div>
{# User menu #} {# User menu #}
<div class="navbar-item" data-controller="dropdown"> <div class="navbar-item" data-controller="dropdown import-status">
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger"> <button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/> <circle cx="12" cy="7" r="4"/>
</svg> </svg>
<span class="badge" data-import-status-target="badge" hidden></span>
</button> </button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden> <div class="dropdown-menu" data-dropdown-target="menu" hidden>
<button class="dropdown-item" data-action="click->import-modal#open">Importer ses films</button> <div class="import-status-item" data-import-status-target="item">
<button class="dropdown-item" data-action="click->import-modal#open" data-import-status-target="importBtn">
Importer ses films
</button>
</div>
<a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a> <a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a>
</div> </div>
</div> </div>