Compare commits
9 Commits
f6d180474a
...
116812b3f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116812b3f8 | ||
|
|
c5d359bb0c | ||
|
|
ded3d063c6 | ||
|
|
2e65b2805a | ||
|
|
dba9b985ee | ||
|
|
a37ac1debd | ||
|
|
6a844542ad | ||
|
|
3edde1c7db | ||
|
|
8942e7f608 |
6
assets/bootstrap.js
vendored
6
assets/bootstrap.js
vendored
@@ -1,12 +1,14 @@
|
||||
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';
|
||||
import ImportHelpController from './controllers/import_help_controller.js';
|
||||
|
||||
const app = startStimulusApp();
|
||||
app.register('dropdown', DropdownController);
|
||||
app.register('notifications', NotificationsController);
|
||||
app.register('import-modal', ImportModalController);
|
||||
app.register('import-status', ImportStatusController);
|
||||
app.register('import-help', ImportHelpController);
|
||||
|
||||
// Register React components for {{ react_component() }} Twig function.
|
||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
||||
|
||||
20
assets/controllers/import_help_controller.js
Normal file
20
assets/controllers/import_help_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
95
assets/controllers/import_status_controller.js
Normal file
95
assets/controllers/import_status_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
film: 'fa-solid fa-film',
|
||||
@@ -46,6 +46,7 @@ export default function ActorPopover({ hintType, hintText }) {
|
||||
<i className={iconClass}></i>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
@@ -54,6 +55,7 @@ export default function ActorPopover({ hintType, hintText }) {
|
||||
>
|
||||
{hintText}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,19 +4,6 @@ import ActorPopover from './ActorPopover';
|
||||
|
||||
export default function GameGrid({ grid, width, middle }) {
|
||||
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">
|
||||
<table id="actors">
|
||||
<tbody>
|
||||
@@ -24,6 +11,7 @@ export default function GameGrid({ grid, width, middle }) {
|
||||
if (row.separator !== undefined) {
|
||||
return (
|
||||
<tr key={rowIndex} className="separator-row">
|
||||
<td className="hint-cell" />
|
||||
{Array.from({ length: middle }, (_, i) => (
|
||||
<td key={i} />
|
||||
))}
|
||||
@@ -44,12 +32,13 @@ export default function GameGrid({ grid, width, middle }) {
|
||||
pos={row.pos}
|
||||
colStart={middle - row.pos}
|
||||
totalWidth={width}
|
||||
hintType={row.hintType}
|
||||
hintText={row.hintText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import LetterInput from './LetterInput';
|
||||
import ActorPopover from './ActorPopover';
|
||||
|
||||
function isLetter(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 letters = actorName.split('');
|
||||
|
||||
@@ -28,6 +29,9 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="hint-cell">
|
||||
<ActorPopover hintType={hintType} hintText={hintText} />
|
||||
</td>
|
||||
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
|
||||
const charIndex = colIndex - colStart;
|
||||
const isInRange = charIndex >= 0 && charIndex < letters.length;
|
||||
|
||||
@@ -50,6 +50,7 @@ body {
|
||||
#actors td {
|
||||
width: var(--cell);
|
||||
height: var(--cell);
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -260,6 +261,10 @@ body {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.navbar-left,
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
@@ -291,22 +296,6 @@ body {
|
||||
|
||||
/* ── 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 ── */
|
||||
|
||||
@@ -362,35 +351,95 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Notifications ── */
|
||||
/* ── Dropdown item row ── */
|
||||
|
||||
.notifications-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
.dropdown-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--surface-warm);
|
||||
.dropdown-item-row .dropdown-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
.info-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-faint);
|
||||
background: none;
|
||||
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 {
|
||||
background: var(--surface-warm);
|
||||
.info-btn:hover {
|
||||
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 ── */
|
||||
@@ -534,33 +583,26 @@ body {
|
||||
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 {
|
||||
height: var(--cell);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint-separator {
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
-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 {
|
||||
overflow-x: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ── Game actions ── */
|
||||
@@ -591,16 +633,47 @@ body {
|
||||
|
||||
.game-start-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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 {
|
||||
padding: 14px 32px;
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,11 @@ services:
|
||||
ports:
|
||||
- "${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:
|
||||
ports:
|
||||
- "0.0.0.0:5432:5432"
|
||||
|
||||
35
migrations/Version20260331000001.php
Normal file
35
migrations/Version20260331000001.php
Normal 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)\'');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260331000002.php
Normal file
26
migrations/Version20260331000002.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ class Movie
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $year = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MovieRole>
|
||||
*/
|
||||
@@ -76,6 +79,18 @@ class Movie
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getYear(): ?int
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function setYear(?int $year): static
|
||||
{
|
||||
$this->year = $year;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, MovieRole>
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Import;
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\UserMovie;
|
||||
use App\Exception\GatewayException;
|
||||
use App\Gateway\LtbxdGateway;
|
||||
use App\Message\ImportFilmsBatchMessage;
|
||||
use App\Repository\ImportRepository;
|
||||
@@ -51,7 +49,8 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
}
|
||||
|
||||
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit);
|
||||
$user = $import->getUser();
|
||||
$userId = $import->getUser()->getId();
|
||||
$importId = $import->getId();
|
||||
|
||||
foreach ($batch as $ltbxdMovie) {
|
||||
try {
|
||||
@@ -63,6 +62,7 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
|
||||
$this->actorSyncer->syncActorsForMovie($movie);
|
||||
|
||||
$user = $this->em->getReference(\App\Entity\User::class, $userId);
|
||||
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
|
||||
'user' => $user,
|
||||
'movie' => $movie,
|
||||
@@ -78,11 +78,14 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Failed to import film', [
|
||||
'film' => $ltbxdMovie->getName(),
|
||||
'importId' => $import->getId(),
|
||||
'importId' => $importId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->importRepository->incrementFailedFilms($import);
|
||||
}
|
||||
|
||||
$this->em->clear();
|
||||
$import = $this->em->getRepository(Import::class)->find($importId);
|
||||
}
|
||||
|
||||
$processedBatches = $this->importRepository->incrementProcessedBatches($import);
|
||||
@@ -94,17 +97,6 @@ readonly class ImportFilmsBatchMessageHandler
|
||||
$import->setStatus(Import::STATUS_COMPLETED);
|
||||
$import->setCompletedAt(new \DateTimeImmutable());
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Entity\Import;
|
||||
use App\Entity\Notification;
|
||||
use App\Gateway\LtbxdGateway;
|
||||
use App\Message\ImportFilmsBatchMessage;
|
||||
use App\Message\ProcessImportMessage;
|
||||
@@ -72,12 +71,6 @@ readonly class ProcessImportMessageHandler
|
||||
|
||||
$import->setStatus(Import::STATUS_FAILED);
|
||||
$this->em->flush();
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->setUser($import->getUser());
|
||||
$notification->setMessage('L\'import a échoué.');
|
||||
$this->em->persist($notification);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ readonly class FilmImporter
|
||||
$movie = new Movie()
|
||||
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
|
||||
->setTitle($ltbxdMovie->getName())
|
||||
->setTmdbId($tmdbMovie->getId());
|
||||
->setTmdbId($tmdbMovie->getId())
|
||||
->setYear($ltbxdMovie->getYear());
|
||||
|
||||
$this->em->persist($movie);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% if app.user %}
|
||||
<div data-controller="import-modal">
|
||||
<nav class="navbar" data-controller="notifications">
|
||||
<div data-controller="import-modal import-help">
|
||||
<nav class="navbar">
|
||||
<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 class="navbar-right">
|
||||
{# Gitea repo #}
|
||||
@@ -12,25 +12,8 @@
|
||||
</svg>
|
||||
</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 #}
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -38,7 +21,16 @@
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,11 +56,30 @@
|
||||
</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">×</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 & 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>
|
||||
{% else %}
|
||||
<nav class="navbar">
|
||||
<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 class="navbar-right">
|
||||
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -29,10 +29,22 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<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') }}">
|
||||
<button type="submit" class="btn btn-primary btn-start">Commencer une partie</button>
|
||||
</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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Connexion — Actorle{% endblock %}
|
||||
{% block title %}Connexion — LtbxdActorle{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Inscription — Actorle{% endblock %}
|
||||
{% block title %}Inscription — LtbxdActorle{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="auth-container">
|
||||
|
||||
Reference in New Issue
Block a user