diff --git a/public/control.html b/public/control.html index a72c8b5..e3f71db 100644 --- a/public/control.html +++ b/public/control.html @@ -101,96 +101,96 @@
-
-
-
- -
-

- -

-
-
-
- Pilote tout le site (films, checklist, statistiques). -
-
-
- - -
Basé sur les années présentes dans les films.
-
-
- -
-
+
+
+
+

Réglages

+
Paramètres globaux du site (année, dates, options du lecteur).
+
+
+ + + +
+ +
+
+
+ Pilote tout le site (films, checklist, statistiques). +
+ +
+
+ +
+
Basé sur les années présentes dans les films.
+
- -
-

- -

-
-
-
-
- - -
-
- - -
-
-
- Contrôle l’affichage du bloc debug dans player.html (admins uniquement). -
+ +
+
+
+
+ + +
+
+ +
+
+ Contrôle l’affichage du bloc debug dans player.html (admins uniquement). +
+
- -
-

- -

-
-
-
- Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: 15 mars. -
+ +
+
+
+ Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: 15 mars. +
-
-
- - -
Basé sur les années présentes dans les films.
-
-
- - -
Format: YYYY-MM-DD.
-
-
- - -
-
- -
-
Dates configurées
-
—
-
+
+
+ + +
Basé sur les années présentes dans les films.
+
+ + +
Format: YYYY-MM-DD.
+
+
+ + +
+
+ +
+
Dates configurées
+
—
@@ -199,59 +199,125 @@
-
-
-
-

Version active (footer)

-
- Remplace complètement le checker GitHub. La version affichée dans le footer vient de la DB et peut être changée ici. -
- - - -
- - -
Cette sélection impacte ce que les utilisateurs voient dans le footer.
-
- -
- - -
- -
-
Aperçu (format footer)
-
—
-
+
+
+
+

Version

+
Contrôle la version affichée dans le footer (stockée en DB).
-
-
-

Créer une nouvelle version

-
- Ajoute une entrée dans l’historique, puis tu peux la rendre active. -
+ + + +
+ +
-
- - +
+
+
Version active
+
+ Met à jour ce que les utilisateurs voient dans le footer. +
+ + +
+
+ +
La liste vient de l’historique stocké en DB.
+
+
+ + +
+
+ +
+
Aperçu (footer)
+
—
+
+
-
- - -
-
- - + +
+
+
Bon à savoir
+
    +
  • “Recharger” relit l’historique depuis l’API.
  • +
  • “Activer” change la version active en DB.
  • +
  • Le footer lit ensuite la version via /api/version.
  • +
+
+
-
-
Historique
-
—
+ +
+
+
+
Historique
+
Supprime les entrées obsolètes si besoin.
+
+
+
—
+
+ + +
+
+
+
+
Créer une nouvelle version
+
+ Ajoute une entrée dans l’historique (tu peux l’activer immédiatement). +
+ +
+
+ + +
Ex: 1.2.3
+
+
+ + +
Court & lisible (affiché dans le footer).
+
+
+ + +
+
+
+
+ +
+
+
Astuce
+
+ Après création, va dans l’onglet “Active” pour vérifier l’aperçu du footer. +
+
+
@@ -259,64 +325,116 @@
-
-
-
-

Contenu “Modal 100%”

-

- Contenu affiché quand un utilisateur a vu 100% des films dans la checklist. -

- -
- - -
- -
- - -
Affiché en texte brut (safe). Laisse vide si tu n’en veux pas.
-
- -
- - -
- Affiché comme HTML (le serveur retire les <script> et les handlers on*). -
-
- -
- - -
Chemin relatif (ex: video/reward.mp4) ou URL complète. Vide = pas de vidéo.
-
- -
- - -
+
+
+
+

Modal 100%

+
Contenu affiché quand un utilisateur a vu 100% des films.
-
-
-

Aperçu

-
Aperçu live (non sauvegardé tant que tu n’enregistres pas).
+ -
-
-
—
- Preview +
+ +
+
+
+
+
Contenu
+
+ Modifie le contenu puis enregistre. L’aperçu est live (non sauvegardé tant que tu n’enregistres pas). +
+ +
+ + +
+ +
+ + +
Affiché en texte brut (safe). Laisse vide si tu n’en veux pas.
+
+ +
+ + +
+ Affiché comme HTML (le serveur retire les <script> et les handlers on*). +
+
+ +
+ + +
Chemin relatif (ex: video/reward.mp4) ou URL complète. Vide = pas de vidéo.
+
+ +
+ + +
+
-
-
-
- +
+
+
+
Aperçu
+
+
Aperçu live (non sauvegardé).
+ +
+
+
—
+
+
+
+ +
+ +
+
+
+
+
+
+ + +
+
+
+
+
Règles
+
    +
  • Texte: affiché tel quel (safe), retours à la ligne conservés.
  • +
  • HTML: le serveur retire les <script> et les attributs on*.
  • +
  • Vidéo: accepte un chemin relatif (ex: video/reward.mp4) ou une URL complète.
  • +
+
+
+
+
+
Astuce
+
+ Si tu utilises du HTML, préfère des tags simples (<p>, <a>, <strong>) pour éviter des surprises d’affichage. +
+
@@ -362,92 +480,67 @@
-
-
-
-

Ajouter un film

-

- Ajoute un film via l’API (/api/movies/add). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’imdb_id. -

- -
-
- - -
Permet de séparer les films et les stats par année.
-
-
- - -
-
- - -
-
- -
-
- - -
Auto = video_src → embed_src → vod_link.
-
-
- - -
If you use a server file (below) and leave this empty, it will auto-use /api/video/<movieId>.
-
-
- - -
- Relative path under VIDEO_FILES_DIR (default: public/video). Supports seeking (Range). -
-
-
- - -
-
- - -
Backward-compat. Prefer video_src / embed_src above.
-
-
-
-
- - -
-
+
+
+
+

Films

+
Gestion et ajout des films (par année).
-
-
+ + +
+ +
-

Gérer les films

-
Sélectionne une année, puis coche les films à supprimer.
+
Gérer les films
+
Filtre, sélection et suppression en lot.
+
+
+ —
-
+
-
+ +
+ +
+ + +
+
+ +
-
+ +
@@ -459,7 +552,7 @@
- +
@@ -477,25 +570,153 @@
Sel.
+ + +
+
Ajouter un film
+
+ Le serveur récupère automatiquement titre/plot/poster via OMDb avec l’imdb_id. +
+ +
+
+
+ + +
Sépare films & stats par année.
+
+
+ + +
+
+ + +
+ +
+
+
+

+ +

+
+
+
+
+ + +
Auto = video_src → embed_src → vod_link.
+
+
+ + +
Si video_file est fourni et que ce champ est vide, la lecture utilisera /api/video/<movieId>.
+
+
+ + +
Chemin relatif sous VIDEO_FILES_DIR (défaut: public/video).
+
+
+ + +
+
+ + +
Compat. Préfère video_src / embed_src.
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+ + +
+
+
+
+
Notes rapides
+
    +
  • Auto: utilise video_src → embed_src → vod_link.
  • +
  • Server file: mets video_file (chemin relatif sous public/video).
  • +
  • Édition: le bouton “Éditer” ouvre une modale avec des onglets Info / Player.
  • +
+
+
+
+
+
Astuce
+
+ Dans “Gérer”, la recherche filtre la table localement (sans requête API). Le “Tout sélectionner” ne coche que les lignes visibles. +
+
+
+
+
-
-
-
-

Backup

-

- Crée une sauvegarde de la base MongoDB (format .ndjson.gz) et conserve-la sur le serveur. -

+
+
+
+

Backup / Restore

+
Sauvegarde et restauration de la base MongoDB.
+
+
-
- - + + +
+ +
+
+
+
Sauvegardes
+
Format .ndjson.gz, stocké sur le serveur.
+
+
+ + +
-
+
@@ -511,79 +732,176 @@
-
-
-
-

Restore

-

- Restaure une sauvegarde (fichier .ndjson.gz). Option recommandée: drop pour supprimer les collections avant import. -

+ +
+
+
+
+
Restaurer une sauvegarde
+
+ Restaure un fichier .ndjson.gz. L’option drop est recommandée. +
-
- La restauration peut écraser les données. Fais un backup juste avant si besoin. +
+ La restauration peut écraser les données. Fais un backup juste avant si besoin. +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+ +
+
+
Conseil
+
+ Utilise drop sauf si tu sais exactement ce que tu fais (sinon risque de doublons/erreurs). +
+
+
+
-
- - + +
+
+
+
+
Fonctionnement
+
    +
  • Backup: crée un fichier .ndjson.gz (export MongoDB) et le garde sur le serveur.
  • +
  • Télécharger: récupère le fichier localement (utile avant une manipulation risquée).
  • +
  • Restore: importe le fichier. Avec drop, les collections restaurées sont d’abord supprimées.
  • +
+
+
+
+
+
Check rapide
+
+ Après restore, recharge la page et vérifie “Films”, “Checklist” et “Statistiques”. +
+
+
- -
- - -
- -
- -
- -
-
-
-
-

Définir les gagnants par année (égalité)

-
- Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points). - Choisis un utilisateur + des points (optionnel), puis clique Ajouter. -
+
+
+
+

Gagnants

+
Définis un ou plusieurs gagnants par année (égalité possible).
+
+
+ + +
+ +
-
- - -
Basé sur les années présentes dans les films.
+
+
+
Ajouter un gagnant
+
+ Tu peux ajouter plusieurs gagnants pour une même année (égalité). +
+ +
+
+ + +
Basé sur les années présentes dans les films.
+
+
+ + +
Pour retirer: utilise la liste ci-dessous.
+
+
+ + +
Laisse vide si non applicable.
+
+
+ +
+ + + +
+
-
- - -
Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.
-
-
- - + +
+
+
+
+
Gagnants actuels
+
Vue globale (toutes les années).
+
+
+ +
—
+
+
-
- - - -
- -
-
Gagnants actuels
-
—
+ +
+
+
+
+
Comment ça marche
+
    +
  • Une année peut avoir plusieurs gagnants (égalité).
  • +
  • Les points sont optionnels (affichage uniquement).
  • +
  • Pour retirer un gagnant, utilise le bouton “Retirer” dans la liste “Gagnants actuels”.
  • +
+
+
+
+
+
Astuce
+
+ Si tu changes l’année active du site, vérifie aussi les gagnants de cette année. +
+
+
diff --git a/public/css/app.css b/public/css/app.css index 3d60bd2..8bb9df4 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -166,6 +166,41 @@ footer { border-bottom: 0; } + /* Admin: compact "sub tabs" used inside panels */ + .admin-subtabs { + gap: 0.5rem; + } + + .admin-subtabs .nav-item { + margin: 0; + } + + .admin-subtabs.nav-pills .nav-link { + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + color: #111; + font-weight: 600; + padding: 0.45rem 0.8rem; + border-radius: 10px; + } + + .admin-subtabs.nav-pills .nav-link:hover { + background: rgba(0, 0, 0, 0.03); + } + + .admin-subtabs.nav-pills .nav-link.active { + background: #111; + border-color: #111; + color: #fff; + } + + /* Admin → Version preview: keep it compact */ + .app-version-preview { + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.92rem; + } + /* Movies page helpers (Bootstrap-first, minimal custom) */ .movie-poster { width: 100%; @@ -183,4 +218,117 @@ footer { .watched-badge { z-index: 2; + } + + /* Stats → "Films regardés" modal */ + .stats-movies-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + } + + .stats-movie-item { + display: flex; + gap: 12px; + padding: 12px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 14px; + } + + .stats-movie-item--clickable { + cursor: pointer; + } + + .stats-movie-item--clickable:hover { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.14); + } + + .stats-movie-poster { + width: 56px; + height: 84px; + border-radius: 10px; + object-fit: cover; + background: rgba(0, 0, 0, 0.06); + flex: 0 0 auto; + } + + .stats-movie-poster.stats-movie-poster--empty { + background: linear-gradient(135deg, rgba(0,0,0,0.08), rgba(0,0,0,0.03)); + } + + .stats-movie-title { + font-weight: 700; + line-height: 1.2; + margin: 0; + } + + .stats-movie-meta { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + font-size: 0.875rem; + } + + .stats-movie-details { + margin-top: 6px; + } + + .stats-movie-details summary { + cursor: pointer; + user-select: none; + font-size: 0.85rem; + color: rgba(0, 0, 0, 0.6); + list-style: none; + } + + .stats-movie-details summary::-webkit-details-marker { + display: none; + } + + .stats-movie-details summary::before { + content: "›"; + display: inline-block; + margin-right: 6px; + transform: rotate(0deg); + transition: transform 120ms ease; + } + + .stats-movie-details[open] summary::before { + transform: rotate(90deg); + } + + .stats-movie-details-body { + margin-top: 6px; + padding-left: 14px; + line-height: 1.25rem; + overflow-wrap: anywhere; + } + + .stats-movie-imdb { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.2rem 0.45rem; + line-height: 1.1; + } + + .stats-movie-imdb .imdb-icon { + width: 18px; + height: 18px; + margin-left: 0; + } + + /* Admin → Settings: keep controls compact */ + .admin-settings-narrow { + max-width: 520px; + } + + @media (max-width: 576px) { + .admin-settings-narrow { + max-width: 100%; + } } \ No newline at end of file diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index 5823cdb..77e4d54 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -159,7 +159,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchCompletionModalContent() { try { - const res = await fetch('/api/settings/completion-modal', { method: 'GET' }); + const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch completion modal'); const data = await res.json(); return { @@ -217,7 +217,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchActiveYear() { try { - const res = await fetch('/api/settings/year', { method: 'GET' }); + const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch active year'); const data = await res.json(); const year = Number(data?.year); @@ -229,7 +229,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchOscarEffectiveDate(year) { try { - const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' }); + const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch oscar date'); const data = await res.json(); const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null; @@ -247,7 +247,8 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchMoviesSummary(year) { const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, { method: 'GET', - headers: { 'Authorization': `Bearer ${token}` } + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }); if (!res.ok) throw new Error('Failed to fetch movies summary'); return await res.json(); @@ -346,7 +347,8 @@ window.addEventListener('DOMContentLoaded', async function () { fetchCompletionModalContent(), fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, { method: 'GET', - headers: { 'Authorization': `Bearer ${token}` } + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }) ]); @@ -498,7 +500,8 @@ window.addEventListener('DOMContentLoaded', async function () { const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', { method: 'GET', - headers: { 'Authorization': `Bearer ${token}` } + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }); if (updatedWatchedMoviesRes.ok) { diff --git a/public/js/app_control.js b/public/js/app_control.js index b5ade3a..79ab475 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -165,7 +165,7 @@ window.onload = async function () { } async function fetchPlayerUi() { - const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' }); + const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET', cache: 'no-store' }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.message || data.error || `Erreur (${res.status})`; @@ -254,7 +254,7 @@ window.onload = async function () { } async function fetchAppVersionState() { - const res = await fetch('/api/settings/app-version', { method: 'GET' }); + const res = await fetch('/api/settings/app-version', { method: 'GET', cache: 'no-store' }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.message || data.error || `Erreur (${res.status})`; @@ -338,7 +338,7 @@ window.onload = async function () { async function refreshAppVersionPreview() { if (!appVersionPreviewEl) return; try { - const res = await fetch('/api/version', { method: 'GET' }); + const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error(`Erreur (${res.status})`); const data = await res.json().catch(() => ({})); const line = `${data?.date || ''} - ${data?.version || ''} - ${data?.message || ''}`.trim(); @@ -462,7 +462,7 @@ window.onload = async function () { } async function fetchCompletionModal() { - const res = await fetch('/api/settings/completion-modal', { method: 'GET' }); + const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error(`Erreur (${res.status})`); const data = await res.json().catch(() => ({})); return normalizeCompletionModal(data); @@ -763,7 +763,8 @@ window.onload = async function () { method: 'GET', headers: { 'Authorization': `Bearer ${token}`, - } + }, + cache: 'no-store', }); const data = await res.json().catch(() => ([])); @@ -805,7 +806,7 @@ window.onload = async function () { } async function fetchOscarDates() { - const res = await fetch('/api/settings/oscar-dates', { method: 'GET' }); + const res = await fetch('/api/settings/oscar-dates', { method: 'GET', cache: 'no-store' }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.message || data.error || `Erreur (${res.status})`; @@ -876,7 +877,7 @@ window.onload = async function () { } async function fetchWinners() { - const res = await fetch('/api/settings/winners', { method: 'GET' }); + const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-store' }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = data.message || data.error || `Erreur (${res.status})`; @@ -986,22 +987,10 @@ window.onload = async function () { return winnersByYear.get(String(y)) || null; } - function renderWinnerCurrent() { - if (!winnerCurrentEl || !winnerYearSelect) return; - const y = parseYear(winnerYearSelect.value); - if (!y) { - winnerCurrentEl.textContent = '—'; - return; - } - const list = getWinnerForYear(y); + function sortWinnersForDisplay(list) { const winners = Array.isArray(list) ? list : []; - if (!winners.length) { - winnerCurrentEl.textContent = `${y}: (aucun gagnant)`; - return; - } - // Sort for stable display: points desc (null last), then name - const sorted = winners.slice().sort((a, b) => { + return winners.slice().sort((a, b) => { const ap = a?.points === null || a?.points === undefined ? null : Number(a.points); const bp = b?.points === null || b?.points === undefined ? null : Number(b.points); if (ap === null && bp !== null) return 1; @@ -1009,25 +998,57 @@ window.onload = async function () { if (ap !== null && bp !== null && ap !== bp) return bp - ap; return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' }); }); + } - const rows = sorted.map((w) => { - const name = w?.name || '(utilisateur supprimé)'; - const pts = w?.points === null || w?.points === undefined ? null : Number(w.points); - const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` — ${pts} pts`; - const uid = String(w?.userId || ''); + function renderWinnerCurrent() { + if (!winnerCurrentEl) return; + + const years = Array.from(winnersByYear.keys()) + .map((k) => Number(k)) + .filter((n) => Number.isInteger(n)) + .sort((a, b) => b - a); + + if (!years.length) { + winnerCurrentEl.textContent = '—'; + return; + } + + const blocks = years.map((y) => { + const list = winnersByYear.get(String(y)) || []; + const sorted = sortWinnersForDisplay(list); + + if (!sorted.length) { + return ` +
+
${escapeHtml(String(y))}
+
(aucun gagnant)
+
+ `; + } + + const rows = sorted.map((w) => { + const name = escapeHtml(w?.name || '(utilisateur supprimé)'); + const pts = w?.points === null || w?.points === undefined ? null : Number(w.points); + const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` — ${escapeHtml(String(Math.round(pts)))} pts`; + const uid = escapeHtml(String(w?.userId || '').trim()); + return ( + `
` + + `
${name}${ptsLabel}
` + + `` + + `
` + ); + }).join(''); + + const tieLabel = sorted.length > 1 ? ' (égalité)' : ''; return ` -
-
${name}${ptsLabel}
- +
+
${escapeHtml(String(y))}${tieLabel}
+ ${rows}
`; }).join(''); - const tieLabel = sorted.length > 1 ? ' (égalité)' : ''; - winnerCurrentEl.innerHTML = ` -
${y}${tieLabel}
- ${rows} - `; + winnerCurrentEl.innerHTML = blocks; winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => { btn.addEventListener('click', async () => { @@ -1109,7 +1130,7 @@ window.onload = async function () { async function fetchActiveYear() { try { - const res = await fetch('/api/settings/year', { method: 'GET' }); + const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-store' }); if (!res.ok) throw new Error('Failed to fetch active year'); const data = await res.json(); const year = Number(data?.year); @@ -1516,6 +1537,7 @@ window.onload = async function () { const res = await fetch('/api/admin/db/backups', { method: 'GET', headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-store', }); const data = await res.json().catch(() => ({})); if (!res.ok) { @@ -1915,11 +1937,6 @@ window.onload = async function () { return; } - if (!vod_link && !video_src && !video_file && !embed_src) { - showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).'); - return; - } - try { const res = await fetch('/api/movies/add', { method: 'POST', @@ -1953,7 +1970,7 @@ window.onload = async function () { async function refreshYears() { try { - const yearsRes = await fetch('/api/movies/years'); + const yearsRes = await fetch('/api/movies/years', { cache: 'no-store' }); const years = yearsRes.ok ? await yearsRes.json() : []; const cleanedYears = (Array.isArray(years) ? years : []) .map((y) => Number(y)) @@ -2167,11 +2184,6 @@ window.onload = async function () { return; } - if (!vod_link && !video_src && !video_file && !embed_src) { - showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).'); - return; - } - const body = { imdb_id, year: yearRaw === '' ? null : year, @@ -2235,7 +2247,7 @@ window.onload = async function () { adminMoviesBody.innerHTML = `Chargement…`; try { - const res = await fetch(url, { method: 'GET' }); + const res = await fetch(url, { method: 'GET', cache: 'no-store' }); if (!res.ok) { adminMoviesBody.innerHTML = `Erreur lors du chargement (${res.status})`; return; diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 1a3d890..b3aea54 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -162,7 +162,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchActiveYear() { try { - const res = await fetch('/api/settings/year', { method: 'GET' }); + const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch active year'); const data = await res.json(); const year = Number(data?.year); @@ -174,7 +174,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchOscarEffectiveDate(year) { try { - const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' }); + const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch oscar date'); const data = await res.json(); const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null; @@ -194,6 +194,7 @@ window.addEventListener('DOMContentLoaded', async function () { const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }); if (!res.ok) throw new Error('Failed to fetch movies summary'); return await res.json(); @@ -319,6 +320,7 @@ window.addEventListener('DOMContentLoaded', async function () { const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }); if (!res.ok) { @@ -348,15 +350,26 @@ window.addEventListener('DOMContentLoaded', async function () { const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id); const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id); const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : ''; - const hasVideoSrc = !!(movie && typeof movie.video_src === 'string' && movie.video_src.trim()); - const hasEmbedSrc = !!(movie && typeof movie.embed_src === 'string' && movie.embed_src.trim()); + const cleanUrl = (v) => { + const s = (typeof v === 'string' ? v.trim() : ''); + // Treat placeholders as "no link" so you never need to store '#' + if (!s || s === '#' || s.toLowerCase() === 'about:blank') return ''; + return s; + }; + const hasVideoSrc = !!cleanUrl(movie?.video_src); + const hasEmbedSrc = !!cleanUrl(movie?.embed_src); const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc; - const hasLegacy = !!(movie && typeof movie.vod_link === 'string' && movie.vod_link.trim()); + const legacyUrl = cleanUrl(movie?.vod_link); + const hasLegacy = !!legacyUrl; - // If the movie only has legacy `vod_link`, skip the player entirely and open the original URL in a new tab. + // Navigation: + // - Prefer in-app player when we have a non-legacy source + // - If only legacy exists, open the legacy URL + // - If there is no source at all, render as non-clickable (no "#") const playerUrl = (movie && movie._id && hasNewPlayerSource) ? `/player.html?id=${encodeURIComponent(movie._id)}` - : (hasLegacy ? movie.vod_link : (movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : '#')); + : (hasLegacy ? legacyUrl : ''); + const isClickable = !!playerUrl; const movieDiv = document.createElement('div'); movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card'); @@ -364,15 +377,25 @@ window.addEventListener('DOMContentLoaded', async function () { movieDiv.innerHTML = `
${isChecked ? 'VISIONNÉ' : ''} - -
- ${movie.title} -
-
+ ${ + isClickable + ? ` +
+ ${movie.title} +
+
` + : `
+ ${movie.title} +
` + }
- ${movie.title} + ${ + isClickable + ? `${movie.title}` + : `${movie.title}` + }
⭐ ${movie.rating} @@ -381,6 +404,7 @@ window.addEventListener('DOMContentLoaded', async function () {
+ ${!isClickable ? '
Aucune source
' : ''}
${movie.category}

${movie.description}

${isChecked ? `
Regardé le: ${watchedDate}
` : ''} diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js index 15a5f89..a324225 100644 --- a/public/js/app_user-stat.js +++ b/public/js/app_user-stat.js @@ -130,7 +130,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchActiveYear() { try { - const res = await fetch('/api/settings/year', { method: 'GET' }); + const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch active year'); const data = await res.json(); const year = Number(data?.year); @@ -142,7 +142,7 @@ window.addEventListener('DOMContentLoaded', async function () { async function fetchWinners() { try { - const res = await fetch('/api/settings/winners', { method: 'GET' }); + const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-cache' }); if (!res.ok) throw new Error('Failed to fetch winners'); const data = await res.json().catch(() => ({})); return Array.isArray(data?.winners) ? data.winners : []; @@ -155,7 +155,8 @@ window.addEventListener('DOMContentLoaded', async function () { try { const res = await fetch('/api/users/completions', { method: 'GET', - headers: { 'Authorization': `Bearer ${token}` } + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }); if (!res.ok) throw new Error('Failed to fetch completions'); const data = await res.json().catch(() => ({})); @@ -253,7 +254,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (!completions || typeof completions !== 'object') { tbody.innerHTML = ` - Impossible de charger les finisseurs 100%. + Impossible de charger les finisseurs 100%. `; return; @@ -266,7 +267,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (!years.length) { tbody.innerHTML = ` - Aucune année trouvée. + Aucune année trouvée. `; return; @@ -282,7 +283,6 @@ window.addEventListener('DOMContentLoaded', async function () { const yearStr = String(y); const total = Number(totals?.[yearStr] ?? 0); const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : []; - const count = completers.length; const tr = document.createElement('tr'); @@ -290,17 +290,11 @@ window.addEventListener('DOMContentLoaded', async function () { tdYear.className = 'fw-semibold'; tdYear.textContent = yearStr; - const tdCount = document.createElement('td'); - const badge = document.createElement('span'); - badge.className = 'badge bg-secondary'; - badge.textContent = String(count); - tdCount.appendChild(badge); - const tdTotal = document.createElement('td'); tdTotal.textContent = String(Number.isFinite(total) ? total : 0); const tdNames = document.createElement('td'); - if (!count) { + if (!completers.length) { tdNames.className = 'text-muted'; tdNames.textContent = '—'; } else { @@ -316,7 +310,6 @@ window.addEventListener('DOMContentLoaded', async function () { } tr.appendChild(tdYear); - tr.appendChild(tdCount); tr.appendChild(tdTotal); tr.appendChild(tdNames); tbody.appendChild(tr); @@ -356,6 +349,262 @@ window.addEventListener('DOMContentLoaded', async function () { try { pageLoader.setProgress(12); + // Modal UI (Films regardés) + const moviesModalEl = document.getElementById('moviesModal'); + const movieGridEl = document.getElementById('movie-grid'); + const moviesSummaryEl = document.getElementById('movies-summary'); + const moviesEmptyEl = document.getElementById('movies-empty'); + const moviesModalTitleEl = document.getElementById('moviesModalLabel'); + + let currentModalUserName = ''; + let currentModalMovies = []; + + function safeStr(v) { + return (v === null || v === undefined) ? '' : String(v); + } + + function parseMaybeNumber(v) { + const n = Number(v); + return Number.isFinite(n) ? n : null; + } + + function formatWatchedDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + try { + return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' }); + } catch (_) { + return d.toLocaleDateString(); + } + } + + function normalizePosterUrl(poster) { + const s = safeStr(poster).trim(); + if (!s || s.toUpperCase() === 'N/A') return ''; + return s; + } + + function sortMoviesRecent(list) { + const arr = Array.isArray(list) ? list.slice() : []; + // watchedDate desc, fallback title + arr.sort((a, b) => { + const at = a?.watchedDate ? new Date(a.watchedDate).getTime() : 0; + const bt = b?.watchedDate ? new Date(b.watchedDate).getTime() : 0; + if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at; + return safeStr(a?.title).localeCompare(safeStr(b?.title), 'fr', { sensitivity: 'base' }); + }); + return arr; + } + + function renderMoviesModal() { + if (!movieGridEl || !moviesSummaryEl || !moviesEmptyEl) return; + + const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : [])); + + const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0; + moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film'; + + movieGridEl.innerHTML = ''; + if (!sorted.length) { + moviesEmptyEl.classList.remove('d-none'); + return; + } + moviesEmptyEl.classList.add('d-none'); + + sorted.forEach((movie) => { + const movieId = safeStr(movie?.movieId).trim(); + const imdbId = safeStr(movie?.imdb_id).trim(); + const title = safeStr(movie?.title).trim() || '(sans titre)'; + const category = safeStr(movie?.category).trim(); + const ratingRaw = safeStr(movie?.rating).trim(); + const rating = parseMaybeNumber(ratingRaw); + const watchedLabel = formatWatchedDate(movie?.watchedDate); + const posterUrl = normalizePosterUrl(movie?.poster); + + const cleanUrl = (v) => { + const s = (typeof v === 'string' ? v.trim() : ''); + if (!s || s === '#' || s.toLowerCase() === 'about:blank') return ''; + return s; + }; + + // Click behavior (requested): + // - If vod_link is set => go to it + // - Else if any playable source exists => go to player + // - Else => not clickable + const vodLink = cleanUrl(movie?.vod_link); + const hasPlayableSource = !!cleanUrl(movie?.video_src) || !!cleanUrl(movie?.embed_src) || !!cleanUrl(movie?.video_file); + const isClickable = !!vodLink || (!!movieId && hasPlayableSource); + + const item = document.createElement('div'); + item.className = 'stats-movie-item'; + if (isClickable) { + item.classList.add('stats-movie-item--clickable'); + item.setAttribute('role', 'link'); + item.setAttribute('tabindex', '0'); + item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`); + } + + const poster = document.createElement('img'); + poster.className = 'stats-movie-poster'; + poster.alt = title; + if (posterUrl) { + poster.src = posterUrl; + } else { + poster.classList.add('stats-movie-poster--empty'); + // Keep src empty; browser will not request anything. + poster.src = ''; + } + poster.addEventListener('error', () => { + poster.classList.add('stats-movie-poster--empty'); + try { poster.removeAttribute('src'); } catch (_) {} + }); + + function openIfPossible(evt) { + if (!isClickable) return; + // Don't hijack clicks inside controls (IMDb button, details summary, etc.) + const target = evt?.target; + if (target && typeof target.closest === 'function') { + if (target.closest('a, button, summary, details')) return; + } + + if (vodLink) { + // Use a new tab (keeps the app state intact) + try { + const w = window.open(vodLink, '_blank', 'noopener,noreferrer'); + if (!w) window.location.href = vodLink; + } catch (_) { + window.location.href = vodLink; + } + return; + } + + if (movieId && hasPlayableSource) { + window.location.href = `/player.html?id=${encodeURIComponent(movieId)}`; + } + } + + if (isClickable) { + item.addEventListener('click', openIfPossible); + item.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openIfPossible(e); + } + }); + } + + const main = document.createElement('div'); + main.className = 'flex-grow-1'; + + const topRow = document.createElement('div'); + topRow.className = 'd-flex align-items-start justify-content-between gap-2'; + + const titleEl = document.createElement('div'); + titleEl.className = 'stats-movie-title'; + titleEl.textContent = title; + + const right = document.createElement('div'); + right.className = 'text-nowrap'; + + if (imdbId) { + const imdbLink = document.createElement('a'); + imdbLink.className = 'btn btn-outline-dark btn-sm stats-movie-imdb'; + imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`; + imdbLink.target = '_blank'; + imdbLink.rel = 'noopener noreferrer'; + imdbLink.setAttribute('aria-label', 'Voir sur IMDb'); + + const imdbText = document.createElement('span'); + imdbText.className = 'small'; + imdbText.textContent = 'IMDb'; + + const imdbIcon = document.createElement('img'); + imdbIcon.src = '/img/imdb.png'; + imdbIcon.className = 'imdb-icon'; + imdbIcon.alt = 'IMDb'; + + imdbLink.appendChild(imdbText); + imdbLink.appendChild(imdbIcon); + right.appendChild(imdbLink); + } + + topRow.appendChild(titleEl); + topRow.appendChild(right); + + const meta = document.createElement('div'); + meta.className = 'stats-movie-meta'; + + if (rating !== null && rating > 0) { + const r = document.createElement('span'); + r.className = 'text-muted'; + r.textContent = `⭐ ${rating.toFixed(1)}`; + meta.appendChild(r); + } else if (ratingRaw) { + const r = document.createElement('span'); + r.className = 'text-muted'; + r.textContent = `⭐ ${ratingRaw}`; + meta.appendChild(r); + } + + if (watchedLabel) { + const d = document.createElement('span'); + d.className = 'text-muted'; + d.textContent = `Vu le ${watchedLabel}`; + meta.appendChild(d); + } + + main.appendChild(topRow); + if (meta.childNodes.length) main.appendChild(meta); + + // Optional: keep long strings (like category) out of the main layout. + if (category) { + const details = document.createElement('details'); + details.className = 'stats-movie-details'; + + const summary = document.createElement('summary'); + summary.textContent = 'Détails'; + details.appendChild(summary); + + const body = document.createElement('div'); + body.className = 'stats-movie-details-body text-muted small'; + body.textContent = category; + details.appendChild(body); + + main.appendChild(details); + } + + item.appendChild(poster); + item.appendChild(main); + movieGridEl.appendChild(item); + }); + } + + function openMoviesModalForUser(userStat) { + currentModalUserName = safeStr(userStat?.name).trim(); + currentModalMovies = Array.isArray(userStat?.watchedMovies) ? userStat.watchedMovies : []; + + if (moviesModalTitleEl) { + const count = currentModalMovies.length; + moviesModalTitleEl.textContent = currentModalUserName + ? `Films regardés — ${currentModalUserName} (${count})` + : `Films regardés (${count})`; + } + + renderMoviesModal(); + + if (moviesModalEl) { + const modal = new bootstrap.Modal(moviesModalEl); + modal.show(); + } + } + + if (moviesModalEl) { + moviesModalEl.addEventListener('shown.bs.modal', () => { + // no controls to focus + }); + } + // Show loading states immediately (avoid "blank" panels) const table = document.querySelector('.user-table'); const statsError = document.getElementById('stats-error'); @@ -366,8 +615,14 @@ window.addEventListener('DOMContentLoaded', async function () { const activeYear = await fetchActiveYear(); if (activeYear) { document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`; + // Keep the main heading clean; show the year in the "Classement" tab instead. const h2 = document.querySelector('h2'); - if (h2) h2.textContent = `Statistiques des utilisateurs (${activeYear})`; + if (h2) h2.textContent = 'Statistiques des utilisateurs'; + const badge = document.getElementById('stats-active-year-badge'); + if (badge) { + badge.textContent = String(activeYear); + badge.classList.remove('d-none'); + } } const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats'; @@ -375,7 +630,8 @@ window.addEventListener('DOMContentLoaded', async function () { const [statsRes, winners, completions] = await Promise.all([ fetch(statsUrl, { method: 'GET', - headers: { 'Authorization': `Bearer ${token}` } + headers: { 'Authorization': `Bearer ${token}` }, + cache: 'no-cache', }), fetchWinners(), fetchCompletions(), @@ -402,13 +658,26 @@ window.addEventListener('DOMContentLoaded', async function () { stats.forEach(userStat => { const userRow = document.createElement('tr'); - userRow.innerHTML = ` - - ${userStat.name} - - ${userStat.watchedCount} - ${userStat.watchedRatio} - `; + const nameTd = document.createElement('td'); + const link = document.createElement('a'); + link.href = '#'; + link.className = 'user-link'; + link.textContent = userStat.name; + link.addEventListener('click', function (event) { + event.preventDefault(); + openMoviesModalForUser(userStat); + }); + nameTd.appendChild(link); + + const countTd = document.createElement('td'); + countTd.textContent = String(userStat.watchedCount ?? ''); + + const ratioTd = document.createElement('td'); + ratioTd.textContent = String(userStat.watchedRatio ?? ''); + + userRow.appendChild(nameTd); + userRow.appendChild(countTd); + userRow.appendChild(ratioTd); userTableBody.appendChild(userRow); }); @@ -417,32 +686,6 @@ window.addEventListener('DOMContentLoaded', async function () { if (statsError) statsError.classList.add('d-none'); pageLoader.setProgress(92); - // Add event listener for user links - document.querySelectorAll('.user-link').forEach(link => { - link.addEventListener('click', function (event) { - event.preventDefault(); - const datasetMovies = this.dataset.movies; - console.log(datasetMovies); - const movies = JSON.parse(datasetMovies); - - // Clear the current movie list in the modal - const movieList = document.getElementById('movie-list'); - movieList.innerHTML = ''; - - // Populate the modal with the movies the user has watched - movies.forEach(movie => { - const movieItem = document.createElement('li'); - movieItem.classList.add('list-group-item'); - movieItem.textContent = movie.title; - movieList.appendChild(movieItem); - }); - - // Show the modal - const modal = new bootstrap.Modal(document.getElementById('moviesModal')); - modal.show(); - }); - }); - pageLoader.done(); } catch (error) { console.error('Error loading user statistics:', error); diff --git a/public/user-stat.html b/public/user-stat.html index 27e3746..fb28ff6 100644 --- a/public/user-stat.html +++ b/public/user-stat.html @@ -84,6 +84,7 @@