diff --git a/public/control.html b/public/control.html index e455012..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
+
—
diff --git a/public/css/app.css b/public/css/app.css index b5e236c..8bb9df4 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -218,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 4839be4..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})`; @@ -1129,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); @@ -1536,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) { @@ -1935,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', @@ -1973,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)) @@ -2187,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, @@ -2255,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 @@