-
- 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.
+
+
+
+
+
+
+
+
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É' : ''}
-
-
-

-
-
+ ${
+ isClickable
+ ? `
+
+

+
+ `
+ : `
+

+
`
+ }
⭐ ${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 @@
@@ -141,20 +142,18 @@
Utilisateurs ayant terminé 100% (par année)
-
Completion = 100% pour l’année (tous les films de l’année vus).
| Année |
- Finisseur(s) |
Total films |
Noms |
- | Chargement… |
+ Chargement… |
@@ -165,16 +164,19 @@
-
+
diff --git a/routes/adminDb.js b/routes/adminDb.js
index 301b7c3..69de98d 100644
--- a/routes/adminDb.js
+++ b/routes/adminDb.js
@@ -152,6 +152,9 @@ async function listBackupFiles() {
router.get("/db/backups", async (req, res) => {
try {
requireAdmin(req);
+ // Admin + auth-scoped data: never allow caching.
+ res.setHeader("Cache-Control", "private, no-store, must-revalidate");
+ res.setHeader("Vary", "Authorization");
const backups = await listBackupFiles();
return res.status(200).json({ backups });
} catch (err) {
@@ -227,6 +230,8 @@ router.post("/db/backup", async (req, res) => {
router.get("/db/backups/:name", async (req, res) => {
try {
requireAdmin(req);
+ res.setHeader("Cache-Control", "private, no-store, must-revalidate");
+ res.setHeader("Vary", "Authorization");
const safe = safeBackupFileName(req.params?.name);
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
diff --git a/routes/movies.js b/routes/movies.js
index 2221459..6a80f7d 100644
--- a/routes/movies.js
+++ b/routes/movies.js
@@ -291,6 +291,9 @@ router.get("/watchedMovies", async (req, res) => {
const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
if (!user) return res.status(404).json({ message: "User not found" });
+ // User-specific: never allow shared/proxy caching.
+ res.set("Cache-Control", "private, no-store, must-revalidate");
+ res.set("Vary", "Authorization");
res.status(200).json(user.watchedMovies); // Return the list of watched movies
} catch (error) {
res.status(500).json({ error: error.message });
@@ -361,16 +364,6 @@ router.post("/add", async (req, res) => {
if (normalizedMode === null) {
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
}
- if (
- !hasAnySourceIncludingFile({
- vod_link: normalizedVod || "",
- video_src: normalizedVideo || "",
- embed_src: normalizedEmbed || "",
- video_file: normalizedFile || "",
- })
- ) {
- return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." });
- }
// Fetch movie details from OMDb API
let movieDetails;
@@ -584,17 +577,6 @@ router.put("/:id", async (req, res) => {
if (poster !== undefined) movie.poster = poster;
}
- if (
- !hasAnySourceIncludingFile({
- vod_link: movie.vod_link || "",
- video_src: movie.video_src || "",
- embed_src: movie.embed_src || "",
- video_file: movie.video_file || "",
- })
- ) {
- return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src / video_file)." });
- }
-
await movie.save();
cacheClear();
return res.status(200).json(movie);
diff --git a/routes/users.js b/routes/users.js
index a6d1387..8c0ec0a 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -122,14 +122,20 @@ async function getOrInitActiveYear() {
// Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => {
try {
- // Fetch all users
- const users = await User.find();
+ // Auth-scoped response: never allow caching.
+ res.set("Cache-Control", "private, no-store, must-revalidate");
+ res.set("Vary", "Authorization");
+
+ // Fetch all users (only needed fields)
+ const users = await User.find().select("name watchedMovies").lean();
// If a year is explicitly provided, use it; otherwise default to the global active year.
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
// Fetch movies (optionally filtered by year)
- const allMovies = await Movie.find({ year });
+ const allMovies = await Movie.find({ year })
+ .select("imdb_id title poster rating category year vod_link player_mode video_src embed_src video_file")
+ .lean();
const totalMoviesCount = allMovies.length;
if (totalMoviesCount === 0) {
@@ -140,19 +146,35 @@ router.get("/stats", verifyToken, async (req, res) => {
const userStats = users.map((user) => {
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
- // Get watched movies with details from the database
- const watchedMovies = user.watchedMovies
- .map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
- .filter(Boolean); // Remove any `undefined` values (if movie is not found)
+ // Join watched movies with movie details for the selected year (keep watchedDate)
+ const watchedRaw = Array.isArray(user?.watchedMovies) ? user.watchedMovies : [];
+ const watchedMoviesDetails = watchedRaw
+ .map((wm) => {
+ const imdbId = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
+ if (!imdbId) return null;
+ const movie = movieByImdbId.get(imdbId);
+ if (!movie) return null;
+ return {
+ movieId: movie?._id ? String(movie._id) : null,
+ imdb_id: movie.imdb_id,
+ title: movie.title || "",
+ poster: movie.poster || "",
+ rating: movie.rating || "",
+ category: movie.category || "",
+ year: Number.isInteger(movie.year) ? movie.year : year,
+ vod_link: movie.vod_link || "",
+ player_mode: movie.player_mode || "auto",
+ video_src: movie.video_src || "",
+ embed_src: movie.embed_src || "",
+ video_file: movie.video_file || "",
+ watchedDate: wm?.watchedDate ? new Date(wm.watchedDate).toISOString() : null,
+ };
+ })
+ .filter(Boolean);
- const watchedCount = watchedMovies.length;
+ const watchedCount = watchedMoviesDetails.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
- const watchedMoviesDetails = watchedMovies.map((movie) => ({
- imdb_id: movie.imdb_id,
- title: movie.title
- }));
-
return {
name: user.name,
year,
@@ -174,6 +196,10 @@ router.get("/stats", verifyToken, async (req, res) => {
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
router.get("/completions", verifyToken, async (req, res) => {
try {
+ // Auth-scoped response: never allow caching.
+ res.set("Cache-Control", "private, no-store, must-revalidate");
+ res.set("Vary", "Authorization");
+
const yearsRaw = await Movie.distinct("year");
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
.map((y) => Number(y))
@@ -376,6 +402,10 @@ router.get("/admin/list", verifyToken, async (req, res) => {
}
try {
+ // Admin + auth-scoped data: never allow caching.
+ res.set("Cache-Control", "private, no-store, must-revalidate");
+ res.set("Vary", "Authorization");
+
const users = await User.find()
.select("name email role mustChangePassword")
.sort({ name: 1, email: 1 });