Compare commits

...

9 Commits

Author SHA1 Message Date
thibaud-leclere
116812b3f8 save movies release date in BDD, remove unused badge, add help to export movie from letterboxd 2026-03-31 22:18:46 +02:00
thibaud-leclere
c5d359bb0c remove unused sync commands (app:sync-actors, app:sync-films)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:55:08 +02:00
thibaud-leclere
ded3d063c6 fix: render hint popover via FloatingPortal to prevent overflow clipping
The popover was invisible because it rendered inside the table's
overflow-x:auto scroll container. FloatingPortal moves it to document.body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:49:46 +02:00
thibaud-leclere
2e65b2805a feat: add login hint on start page for unauthenticated users
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:49:41 +02:00
thibaud-leclere
dba9b985ee feat: rename site title to LtbxdActorle with dual-color branding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:38:26 +02:00
thibaud-leclere
a37ac1debd fix: resolve messenger worker OOM by clearing EntityManager and disabling debug
Clear Doctrine identity map after each film import to prevent memory
accumulation. Run messenger with --no-debug and higher PHP memory_limit
to avoid profiling overhead in dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:11 +02:00
thibaud-leclere
6a844542ad 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>
2026-03-31 21:34:05 +02:00
thibaud-leclere
3edde1c7db feat: add loading spinner when starting a new game
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:28:08 +02:00
thibaud-leclere
8942e7f608 fix: integrate hint buttons into table for perfect row alignment and sticky scroll
Move hint buttons from a separate flex column into the table as the
first <td> of each row, ensuring pixel-perfect alignment with grid rows.
Use position:sticky with box-shadow to keep hints fixed on the left
while scrolling horizontally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:36:32 +02:00
28 changed files with 496 additions and 520 deletions

6
assets/bootstrap.js vendored
View File

@@ -1,12 +1,14 @@
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';
import ImportHelpController from './controllers/import_help_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);
app.register('import-help', ImportHelpController);
// 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

@@ -0,0 +1,20 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['overlay'];
open(event) {
event.stopPropagation();
this.overlayTarget.hidden = false;
}
close() {
this.overlayTarget.hidden = true;
}
closeOnBackdrop(event) {
if (event.target === this.overlayTarget) {
this.close();
}
}
}

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,95 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['item', 'importBtn'];
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();
}
_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');
}
_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');
}
_showFailed() {
this.importBtnTarget.disabled = false;
this.importBtnTarget.textContent = 'Importer ses films';
this._setStatus('Dernier import : échoué', 'failed');
}
_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

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size } from '@floating-ui/react'; import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size, FloatingPortal } from '@floating-ui/react';
const HINT_ICONS = { const HINT_ICONS = {
film: 'fa-solid fa-film', film: 'fa-solid fa-film',
@@ -46,6 +46,7 @@ export default function ActorPopover({ hintType, hintText }) {
<i className={iconClass}></i> <i className={iconClass}></i>
</button> </button>
{isOpen && ( {isOpen && (
<FloatingPortal>
<div <div
ref={refs.setFloating} ref={refs.setFloating}
style={floatingStyles} style={floatingStyles}
@@ -54,6 +55,7 @@ export default function ActorPopover({ hintType, hintText }) {
> >
{hintText} {hintText}
</div> </div>
</FloatingPortal>
)} )}
</> </>
); );

View File

@@ -4,19 +4,6 @@ import ActorPopover from './ActorPopover';
export default function GameGrid({ grid, width, middle }) { export default function GameGrid({ grid, width, middle }) {
return ( return (
<div className="game-grid-area">
<div className="hint-col">
{grid.map((row, rowIndex) => {
if (row.separator !== undefined) {
return <div key={rowIndex} className="hint-separator" />;
}
return (
<div key={rowIndex} className="hint-cell">
<ActorPopover hintType={row.hintType} hintText={row.hintText} />
</div>
);
})}
</div>
<div className="game-grid-scroll"> <div className="game-grid-scroll">
<table id="actors"> <table id="actors">
<tbody> <tbody>
@@ -24,6 +11,7 @@ export default function GameGrid({ grid, width, middle }) {
if (row.separator !== undefined) { if (row.separator !== undefined) {
return ( return (
<tr key={rowIndex} className="separator-row"> <tr key={rowIndex} className="separator-row">
<td className="hint-cell" />
{Array.from({ length: middle }, (_, i) => ( {Array.from({ length: middle }, (_, i) => (
<td key={i} /> <td key={i} />
))} ))}
@@ -44,12 +32,13 @@ export default function GameGrid({ grid, width, middle }) {
pos={row.pos} pos={row.pos}
colStart={middle - row.pos} colStart={middle - row.pos}
totalWidth={width} totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
/> />
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
); );
} }

View File

@@ -1,11 +1,12 @@
import React, { useRef, useCallback, useMemo } from 'react'; import React, { useRef, useCallback, useMemo } from 'react';
import LetterInput from './LetterInput'; import LetterInput from './LetterInput';
import ActorPopover from './ActorPopover';
function isLetter(ch) { function isLetter(ch) {
return /[a-zA-Z]/.test(ch); return /[a-zA-Z]/.test(ch);
} }
export default function GameRow({ actorName, pos, colStart, totalWidth }) { export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
const inputRefs = useRef([]); const inputRefs = useRef([]);
const letters = actorName.split(''); const letters = actorName.split('');
@@ -28,6 +29,9 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
return ( return (
<tr> <tr>
<td className="hint-cell">
<ActorPopover hintType={hintType} hintText={hintText} />
</td>
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => { {Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
const charIndex = colIndex - colStart; const charIndex = colIndex - colStart;
const isInRange = charIndex >= 0 && charIndex < letters.length; const isInRange = charIndex >= 0 && charIndex < letters.length;

View File

@@ -50,6 +50,7 @@ body {
#actors td { #actors td {
width: var(--cell); width: var(--cell);
height: var(--cell); height: var(--cell);
padding: 0;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
} }
@@ -260,6 +261,10 @@ body {
text-decoration: none; text-decoration: none;
} }
.brand-prefix {
color: var(--text-muted);
}
.navbar-left, .navbar-left,
.navbar-right { .navbar-right {
display: flex; display: flex;
@@ -291,22 +296,6 @@ body {
/* ── Badge ── */ /* ── Badge ── */
.badge {
position: absolute;
top: 2px;
right: 2px;
background: var(--orange);
color: white;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* ── Dropdown ── */ /* ── Dropdown ── */
@@ -362,35 +351,95 @@ body {
margin: 0; margin: 0;
} }
/* ── Notifications ── */ /* ── Dropdown item row ── */
.notifications-list { .dropdown-item-row {
max-height: 300px; display: flex;
overflow-y: auto; align-items: center;
} }
.notification-item { .dropdown-item-row .dropdown-item {
padding: 10px 16px; flex: 1;
border-bottom: 1px solid var(--surface-warm);
} }
.notification-item:last-child { .info-btn {
border-bottom: none; width: 20px;
} height: 20px;
border-radius: 50%;
.notification-item p { border: 1.5px solid var(--text-faint);
margin: 0 0 3px; background: none;
font-size: 13px;
color: var(--text);
}
.notification-item time {
font-size: 11px;
color: var(--text-faint); color: var(--text-faint);
font-size: 12px;
font-weight: 700;
font-style: italic;
font-family: 'Georgia', serif;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 10px;
transition: border-color 0.15s, color 0.15s, background 0.15s;
} }
.notification-unread { .info-btn:hover {
background: var(--surface-warm); border-color: var(--orange);
color: var(--orange);
background: var(--surface-tint);
}
/* ── Help steps ── */
.help-steps {
margin: 0;
padding-left: 20px;
color: var(--text-muted);
font-size: 14px;
line-height: 1.8;
}
.help-steps code {
background: var(--surface-tint);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.help-steps a {
color: var(--orange);
font-weight: 600;
text-decoration: none;
}
.help-steps a:hover {
text-decoration: underline;
}
/* ── Import status ── */
.import-status-item {
display: flex;
flex-direction: column;
}
.import-status-text {
display: block;
padding: 0 12px 8px;
font-size: 11px;
font-weight: 500;
line-height: 1.3;
}
.import-status-active {
color: var(--orange);
}
.import-status-completed {
color: #16a34a;
}
.import-status-failed {
color: #dc2626;
} }
/* ── Modal ── */ /* ── Modal ── */
@@ -534,33 +583,26 @@ body {
margin: 0; margin: 0;
} }
.game-grid-area {
display: flex;
align-items: flex-start;
margin-top: 16px;
}
.hint-col {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 5px;
flex-shrink: 0;
padding-right: 12px;
}
.hint-cell { .hint-cell {
height: var(--cell); padding: 0;
display: flex; position: sticky;
align-items: center; left: 5px;
} z-index: 1;
background: var(--surface);
.hint-separator { box-shadow:
height: 12px; -5px 0 0 var(--surface),
5px 0 0 var(--surface),
0 -5px 0 var(--surface),
0 5px 0 var(--surface),
-5px -5px 0 var(--surface),
5px -5px 0 var(--surface),
-5px 5px 0 var(--surface),
5px 5px 0 var(--surface);
} }
.game-grid-scroll { .game-grid-scroll {
overflow-x: auto; overflow-x: auto;
margin-top: 16px;
} }
/* ── Game actions ── */ /* ── Game actions ── */
@@ -591,16 +633,47 @@ body {
.game-start-container { .game-start-container {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: calc(100vh - 64px - 80px); min-height: calc(100vh - 64px - 80px);
} }
.start-login-hint {
margin-top: 16px;
color: var(--text-muted);
font-size: 0.85rem;
}
.start-login-hint a {
color: var(--orange);
font-weight: 600;
text-decoration: none;
}
.start-login-hint a:hover {
text-decoration: underline;
}
.btn-start { .btn-start {
padding: 14px 32px; padding: 14px 32px;
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

@@ -11,6 +11,11 @@ services:
ports: ports:
- "${PORT_80:-80}:80" - "${PORT_80:-80}:80"
messenger:
command: ["php", "-d", "memory_limit=512M", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M", "-vv", "--no-debug"]
volumes:
- .:/app
database: database:
ports: ports:
- "0.0.0.0:5432:5432" - "0.0.0.0:5432:5432"

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

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331000002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add year column to movie table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE movie ADD year INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE movie DROP COLUMN year');
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Command;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Service\ActorSyncer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-actors')]
readonly class SyncActorsCommand
{
public function __construct(
private ActorSyncer $actorSyncer,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
foreach ($this->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;
}
}

View File

@@ -1,57 +0,0 @@
<?php
namespace App\Command;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-films')]
readonly class SyncFilmsCommands
{
public function __construct(
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
try {
$ltbxdMovies = $this->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;
}
}

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

@@ -24,6 +24,9 @@ class Movie
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private ?string $title = null; private ?string $title = null;
#[ORM\Column(nullable: true)]
private ?int $year = null;
/** /**
* @var Collection<int, MovieRole> * @var Collection<int, MovieRole>
*/ */
@@ -76,6 +79,18 @@ class Movie
return $this; return $this;
} }
public function getYear(): ?int
{
return $this->year;
}
public function setYear(?int $year): static
{
$this->year = $year;
return $this;
}
/** /**
* @return Collection<int, MovieRole> * @return Collection<int, MovieRole>
*/ */

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

@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\Notification;
use App\Entity\UserMovie; use App\Entity\UserMovie;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway; use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage; use App\Message\ImportFilmsBatchMessage;
use App\Repository\ImportRepository; use App\Repository\ImportRepository;
@@ -51,7 +49,8 @@ readonly class ImportFilmsBatchMessageHandler
} }
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit); $batch = array_slice($ltbxdMovies, $message->offset, $message->limit);
$user = $import->getUser(); $userId = $import->getUser()->getId();
$importId = $import->getId();
foreach ($batch as $ltbxdMovie) { foreach ($batch as $ltbxdMovie) {
try { try {
@@ -63,6 +62,7 @@ readonly class ImportFilmsBatchMessageHandler
$this->actorSyncer->syncActorsForMovie($movie); $this->actorSyncer->syncActorsForMovie($movie);
$user = $this->em->getReference(\App\Entity\User::class, $userId);
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([ $existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
'user' => $user, 'user' => $user,
'movie' => $movie, 'movie' => $movie,
@@ -78,11 +78,14 @@ readonly class ImportFilmsBatchMessageHandler
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->warning('Failed to import film', [ $this->logger->warning('Failed to import film', [
'film' => $ltbxdMovie->getName(), 'film' => $ltbxdMovie->getName(),
'importId' => $import->getId(), 'importId' => $importId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$this->importRepository->incrementFailedFilms($import); $this->importRepository->incrementFailedFilms($import);
} }
$this->em->clear();
$import = $this->em->getRepository(Import::class)->find($importId);
} }
$processedBatches = $this->importRepository->incrementProcessedBatches($import); $processedBatches = $this->importRepository->incrementProcessedBatches($import);
@@ -94,17 +97,6 @@ readonly class ImportFilmsBatchMessageHandler
$import->setStatus(Import::STATUS_COMPLETED); $import->setStatus(Import::STATUS_COMPLETED);
$import->setCompletedAt(new \DateTimeImmutable()); $import->setCompletedAt(new \DateTimeImmutable());
$this->em->flush(); $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();
} }
} }
} }

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\MessageHandler; namespace App\MessageHandler;
use App\Entity\Import; use App\Entity\Import;
use App\Entity\Notification;
use App\Gateway\LtbxdGateway; use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage; use App\Message\ImportFilmsBatchMessage;
use App\Message\ProcessImportMessage; use App\Message\ProcessImportMessage;
@@ -72,12 +71,6 @@ readonly class ProcessImportMessageHandler
$import->setStatus(Import::STATUS_FAILED); $import->setStatus(Import::STATUS_FAILED);
$this->em->flush(); $this->em->flush();
$notification = new Notification();
$notification->setUser($import->getUser());
$notification->setMessage('L\'import a échoué.');
$this->em->persist($notification);
$this->em->flush();
} }
} }
} }

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

@@ -38,7 +38,8 @@ readonly class FilmImporter
$movie = new Movie() $movie = new Movie()
->setLtbxdRef($ltbxdMovie->getLtbxdRef()) ->setLtbxdRef($ltbxdMovie->getLtbxdRef())
->setTitle($ltbxdMovie->getName()) ->setTitle($ltbxdMovie->getName())
->setTmdbId($tmdbMovie->getId()); ->setTmdbId($tmdbMovie->getId())
->setYear($ltbxdMovie->getYear());
$this->em->persist($movie); $this->em->persist($movie);

View File

@@ -1,8 +1,8 @@
{% if app.user %} {% if app.user %}
<div data-controller="import-modal"> <div data-controller="import-modal import-help">
<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"><span class="brand-prefix">Ltbxd</span>Actorle</a>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
{# Gitea repo #} {# Gitea repo #}
@@ -12,25 +12,8 @@
</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"/>
@@ -38,7 +21,16 @@
</svg> </svg>
</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">
<div class="dropdown-item-row">
<button class="dropdown-item" data-action="click->import-modal#open" data-import-status-target="importBtn">
Importer ses films
</button>
<button class="info-btn" data-action="click->import-help#open" title="Comment exporter depuis Letterboxd">
i
</button>
</div>
</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>
@@ -64,11 +56,30 @@
</div> </div>
</div> </div>
</div> </div>
{# Help Modal #}
<div class="modal-overlay" data-import-help-target="overlay" data-action="click->import-help#closeOnBackdrop" hidden>
<div class="modal">
<div class="modal-header">
<h2>Comment exporter ses films</h2>
<button class="modal-close" data-action="click->import-help#close">&times;</button>
</div>
<div class="modal-body">
<ol class="help-steps">
<li>Connectez-vous à votre compte <a href="https://letterboxd.com" target="_blank" rel="noopener noreferrer">Letterboxd</a></li>
<li>Allez dans <strong>Settings</strong> (Paramètres)</li>
<li>Cliquez sur l'onglet <strong>Import &amp; Export</strong></li>
<li>Cliquez sur <strong>Export Your Data</strong></li>
<li>Un fichier ZIP sera téléchargé. Décompressez-le.</li>
<li>Utilisez le fichier <code>watched.csv</code> pour l'import.</li>
</ol>
</div>
</div>
</div>
</div> </div>
{% else %} {% else %}
<nav class="navbar"> <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"><span class="brand-prefix">Ltbxd</span>Actorle</a>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source"> <a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Actorle{% endblock %}</title> <title>{% block title %}LtbxdActorle{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -29,10 +29,22 @@
</div> </div>
{% else %} {% else %}
<div class="game-start-container"> <div class="game-start-container">
<form method="post" action="{{ path('app_game_start') }}"> <form method="post" action="{{ path('app_game_start') }}" id="start-form">
<input type="hidden" name="_token" value="{{ csrf_token('game_start') }}"> <input type="hidden" name="_token" value="{{ csrf_token('game_start') }}">
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button> <button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
</form> </form>
<div class="start-loader" id="start-loader"></div>
{% if not app.user %}
<p class="start-login-hint">
<a href="{{ path('app_login') }}">Connectez-vous</a> pour importer vos propres films
</p>
{% endif %}
<script>
document.getElementById('start-form').addEventListener('submit', function () {
this.style.display = 'none';
document.getElementById('start-loader').style.display = 'block';
});
</script>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Connexion — Actorle{% endblock %} {% block title %}Connexion — LtbxdActorle{% endblock %}
{% block body %} {% block body %}
<div class="auth-container"> <div class="auth-container">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Inscription — Actorle{% endblock %} {% block title %}Inscription — LtbxdActorle{% endblock %}
{% block body %} {% block body %}
<div class="auth-container"> <div class="auth-container">