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 { 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
|
||||||
|
|||||||
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);
|
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);
|
||||||
|
|||||||
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 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,14 +46,16 @@ export default function ActorPopover({ hintType, hintText }) {
|
|||||||
<i className={iconClass}></i>
|
<i className={iconClass}></i>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<FloatingPortal>
|
||||||
ref={refs.setFloating}
|
<div
|
||||||
style={floatingStyles}
|
ref={refs.setFloating}
|
||||||
{...getFloatingProps()}
|
style={floatingStyles}
|
||||||
className="actor-popover"
|
{...getFloatingProps()}
|
||||||
>
|
className="actor-popover"
|
||||||
{hintText}
|
>
|
||||||
</div>
|
{hintText}
|
||||||
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,52 +4,41 @@ 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="game-grid-scroll">
|
||||||
<div className="hint-col">
|
<table id="actors">
|
||||||
{grid.map((row, rowIndex) => {
|
<tbody>
|
||||||
if (row.separator !== undefined) {
|
{grid.map((row, rowIndex) => {
|
||||||
return <div key={rowIndex} className="hint-separator" />;
|
if (row.separator !== undefined) {
|
||||||
}
|
|
||||||
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>
|
|
||||||
{grid.map((row, rowIndex) => {
|
|
||||||
if (row.separator !== undefined) {
|
|
||||||
return (
|
|
||||||
<tr key={rowIndex} className="separator-row">
|
|
||||||
{Array.from({ length: middle }, (_, i) => (
|
|
||||||
<td key={i} />
|
|
||||||
))}
|
|
||||||
<td className="letter-static separator-char">
|
|
||||||
{row.separator === ' ' ? '' : row.separator}
|
|
||||||
</td>
|
|
||||||
{Array.from({ length: width - middle }, (_, i) => (
|
|
||||||
<td key={middle + 1 + i} />
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameRow
|
<tr key={rowIndex} className="separator-row">
|
||||||
key={rowIndex}
|
<td className="hint-cell" />
|
||||||
actorName={row.actorName}
|
{Array.from({ length: middle }, (_, i) => (
|
||||||
pos={row.pos}
|
<td key={i} />
|
||||||
colStart={middle - row.pos}
|
))}
|
||||||
totalWidth={width}
|
<td className="letter-static separator-char">
|
||||||
/>
|
{row.separator === ' ' ? '' : row.separator}
|
||||||
|
</td>
|
||||||
|
{Array.from({ length: width - middle }, (_, i) => (
|
||||||
|
<td key={middle + 1 + i} />
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</tbody>
|
|
||||||
</table>
|
return (
|
||||||
</div>
|
<GameRow
|
||||||
|
key={rowIndex}
|
||||||
|
actorName={row.actorName}
|
||||||
|
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 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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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\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');
|
||||||
|
|||||||
@@ -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)]
|
#[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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
$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);
|
||||||
|
|
||||||
|
|||||||
@@ -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">×</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>
|
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user