feat: replace notifications with import status in profile dropdown
Remove the notification system entirely and show import progress directly in the user dropdown menu. Block new imports while one is already running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
assets/bootstrap.js
vendored
4
assets/bootstrap.js
vendored
@@ -1,12 +1,12 @@
|
||||
import { startStimulusApp } from 'vite-plugin-symfony/stimulus/helpers';
|
||||
import DropdownController from './controllers/dropdown_controller.js';
|
||||
import NotificationsController from './controllers/notifications_controller.js';
|
||||
import ImportModalController from './controllers/import_modal_controller.js';
|
||||
import ImportStatusController from './controllers/import_status_controller.js';
|
||||
|
||||
const app = startStimulusApp();
|
||||
app.register('dropdown', DropdownController);
|
||||
app.register('notifications', NotificationsController);
|
||||
app.register('import-modal', ImportModalController);
|
||||
app.register('import-status', ImportStatusController);
|
||||
|
||||
// Register React components for {{ react_component() }} Twig function.
|
||||
// We register them manually because @symfony/ux-react's registerReactControllerComponents
|
||||
|
||||
@@ -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);
|
||||
|
||||
99
assets/controllers/import_status_controller.js
Normal file
99
assets/controllers/import_status_controller.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['item', 'importBtn', 'badge'];
|
||||
|
||||
connect() {
|
||||
this._poll();
|
||||
this._interval = setInterval(() => this._poll(), 5000);
|
||||
this._onImportStarted = () => this._poll();
|
||||
document.addEventListener('import:started', this._onImportStarted);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearInterval(this._interval);
|
||||
document.removeEventListener('import:started', this._onImportStarted);
|
||||
}
|
||||
|
||||
async _poll() {
|
||||
try {
|
||||
const response = await fetch('/api/imports/latest');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
this._update(data);
|
||||
} catch (e) {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
_update(data) {
|
||||
if (!data) {
|
||||
this._showDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = data.status === 'pending' || data.status === 'processing';
|
||||
|
||||
if (isActive) {
|
||||
this._showActive(data);
|
||||
} else if (data.status === 'completed') {
|
||||
this._showCompleted(data);
|
||||
} else if (data.status === 'failed') {
|
||||
this._showFailed();
|
||||
} else {
|
||||
this._showDefault();
|
||||
}
|
||||
}
|
||||
|
||||
_showDefault() {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
this._removeStatus();
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showActive(data) {
|
||||
this.importBtnTarget.disabled = true;
|
||||
this.importBtnTarget.textContent = 'Import en cours\u2026';
|
||||
|
||||
const progress = data.totalBatches > 0
|
||||
? Math.round((data.processedBatches / data.totalBatches) * 100)
|
||||
: 0;
|
||||
|
||||
this._setStatus(`${progress}% — ${data.totalFilms} films`, 'active');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showCompleted(data) {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
|
||||
const imported = data.totalFilms - data.failedFilms;
|
||||
this._setStatus(`Dernier import : ${imported}/${data.totalFilms} films`, 'completed');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_showFailed() {
|
||||
this.importBtnTarget.disabled = false;
|
||||
this.importBtnTarget.textContent = 'Importer ses films';
|
||||
this._setStatus('Dernier import : échoué', 'failed');
|
||||
this.badgeTarget.hidden = true;
|
||||
}
|
||||
|
||||
_setStatus(text, type) {
|
||||
let statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||
if (!statusEl) {
|
||||
statusEl = document.createElement('span');
|
||||
statusEl.className = 'import-status-text';
|
||||
this.itemTarget.appendChild(statusEl);
|
||||
}
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = `import-status-text import-status-${type}`;
|
||||
}
|
||||
|
||||
_removeStatus() {
|
||||
const statusEl = this.itemTarget.querySelector('.import-status-text');
|
||||
if (statusEl) statusEl.remove();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -363,35 +363,31 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Notifications ── */
|
||||
/* ── Import status ── */
|
||||
|
||||
.notifications-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
.import-status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--surface-warm);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item p {
|
||||
margin: 0 0 3px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notification-item time {
|
||||
.import-status-text {
|
||||
display: block;
|
||||
padding: 0 12px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-unread {
|
||||
background: var(--surface-warm);
|
||||
.import-status-active {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.import-status-completed {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.import-status-failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
@@ -595,6 +591,20 @@ body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.start-loader {
|
||||
display: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-color: #ff6b81;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Game footer ── */
|
||||
|
||||
.game-footer {
|
||||
|
||||
Reference in New Issue
Block a user