Files
oscars-pool/public/js/app_movies.js
T
Mathieu Moran 4308d6babf Implement en/fr localization across admin and user UIs
Introduce client-side localization helper and en/fr locale bundles.

Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text.

Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations).

Update .gitignore to ignore editor metadata and localization helper artifacts.
2026-02-01 20:20:02 -05:00

750 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
async function waitForImagesIn(containerEl, onProgress) {
if (!containerEl) return;
const imgs = Array.from(containerEl.querySelectorAll('img'));
if (imgs.length === 0) return;
let resolved = 0;
const total = imgs.length;
const notify = () => {
if (typeof onProgress === 'function') onProgress(resolved, total);
};
await Promise.race([
Promise.all(
imgs.map((img) => new Promise((resolve) => {
const finish = () => {
resolved += 1;
notify();
resolve();
};
if (img.complete) return finish();
img.addEventListener('load', finish, { once: true });
img.addEventListener('error', finish, { once: true });
}))
),
new Promise((resolve) => window.setTimeout(resolve, 7000)), // safety timeout
]);
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.loadingMovies') : 'Loading movies',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
function formatClock(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function formatRuntime(runtime) {
// Convert "142 min" or "142" to "2h 22min" format
if (!runtime || runtime === 'N/A') return '';
const raw = String(runtime).trim();
// Extract minutes from strings like "142 min", "142min", or just "142"
const match = raw.match(/^(\d+)\s*min/i);
const minutes = match ? parseInt(match[1], 10) : parseInt(raw, 10);
if (!Number.isFinite(minutes) || minutes <= 0) return raw;
if (minutes < 60) return `${minutes}min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}min` : `${h}h`;
}
async function fetchPlaybackProgressByYear(year, token) {
try {
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) return new Map();
const rows = await res.json().catch(() => []);
const map = new Map();
(Array.isArray(rows) ? rows : []).forEach((r) => {
const movieId = typeof r?.movieId === 'string' ? r.movieId : '';
if (!movieId) return;
const time = Number(r?.time);
const duration = r?.duration === null || r?.duration === undefined ? null : Number(r.duration);
map.set(movieId, {
time: Number.isFinite(time) && time >= 0 ? time : 0,
duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null,
});
});
return map;
} catch (_) {
return new Map();
}
}
function setLoadError(el, message) {
if (!el) return;
el.innerHTML = `
<div class="alert alert-danger my-3" role="alert">
${message || 'Erreur lors du chargement.'}
</div>
`;
}
function normalizeRatingValue(v) {
const n = Number(v);
if (!Number.isFinite(n)) return null;
const rounded = Math.round(n);
if (rounded < 1 || rounded > 5) return null;
return rounded;
}
async function saveUserRating(movieId, rating, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({ rating }),
});
if (!res.ok) return null;
return await res.json().catch(() => null);
} catch (_) {
return null;
}
}
async function deleteUserRating(movieId, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
method: 'DELETE',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
if (!res.ok) return null;
return await res.json().catch(() => null);
} catch (_) {
return null;
}
}
function updateStarUi(wrap, rating) {
if (!wrap) return;
const value = Number(rating);
const user = Number.isFinite(value) ? value : null;
if (wrap?.dataset) {
wrap.dataset.currentRating = user ? String(user) : '';
}
wrap.querySelectorAll('.movie-star').forEach((btn) => {
const starValue = Number(btn.getAttribute('data-value'));
const isActive = user && starValue <= user;
btn.classList.toggle('is-active', !!isActive);
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function updateRatingTexts(cardEl, avgRating, ratingsCount, userRating) {
if (!cardEl) return;
const avgEl = cardEl.querySelector('[data-role="avg-rating"]');
const userEl = cardEl.querySelector('[data-role="user-rating"]');
const avg = Number.isFinite(Number(avgRating)) ? Number(avgRating) : null;
const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0;
const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null;
if (avgEl) {
if (avg && count > 0) {
const avgLabel = `${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const translated = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: avgLabel })
: `User rating ${avgLabel}`;
avgEl.textContent = translated;
avgEl.classList.remove('movie-rating-sub--empty');
} else {
avgEl.textContent = '';
avgEl.classList.add('movie-rating-sub--empty');
}
}
if (userEl) {
userEl.textContent = '';
userEl.classList.add('movie-rating-sub--empty');
}
}
async function fetchActiveYear() {
try {
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);
return Number.isInteger(year) ? year : new Date().getFullYear();
} catch (_) {
return new Date().getFullYear();
}
}
async function fetchOscarEffectiveDate(year) {
try {
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;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
// Use local noon to avoid DST/midnight edge cases for "days left" math.
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesSummary(year, token) {
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();
}
function applySummaryToHeader(summary) {
const lastUpdatedEl = document.getElementById('movies-last-updated');
const ratioEl = document.getElementById('watched-ratio');
const lastUpdatedIso = summary?.lastUpdated;
if (lastUpdatedEl) {
if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
if (!Number.isNaN(d.getTime())) {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
try {
lastUpdatedEl.textContent = d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
lastUpdatedEl.textContent = d.toLocaleString();
}
} else {
lastUpdatedEl.textContent = '—';
}
} else {
lastUpdatedEl.textContent = '—';
}
}
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watched, total: total, percent: ratioPct })
: `Watched: ${watched} / ${total} (${ratioPct}%)`;
ratioEl.innerText = txt;
}
}
const activeYear = await fetchActiveYear();
document.title = `Pool Oscars (${activeYear}) - Films`;
const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Render countdown immediately with a deterministic fallback (then refine from settings).
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
const token = localStorage.getItem('auth_token');
if (token) {
try {
const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name;
document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
window.location.href = '/';
}
} catch (error) {
localStorage.removeItem('auth_token');
window.location.href = '/';
}
} else {
window.location.href = '/';
}
const moviesList = document.getElementById('movies-list');
pageLoader.setProgress(18);
let watchedMoviesInYear = [];
let progressByMovieId = new Map();
try {
// Start summary fetch immediately so header can update ASAP.
const summaryPromise = fetchMoviesSummary(activeYear, token)
.then((data) => {
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
applySummaryToHeader(data);
pageLoader.setProgress(32);
return data;
})
.catch(() => {
// Leave the placeholders (—) if summary fails; movies list can still load.
const lastUpdatedEl = document.getElementById('movies-last-updated');
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.textContent = '—';
return null;
});
// Fetch progress in parallel (movies page uses it to show resume/progression).
const progressPromise = fetchPlaybackProgressByYear(activeYear, token)
.then((m) => { progressByMovieId = m instanceof Map ? m : new Map(); })
.catch(() => { progressByMovieId = new Map(); });
pageLoader.setProgress(28);
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
});
if (!res.ok) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
const movies = await res.json();
pageLoader.setProgress(55);
// If summary is still in flight, wait for it before rendering watched banners.
await Promise.all([summaryPromise, progressPromise]);
pageLoader.setProgress(62);
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
const ratioText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.innerText = ratioText;
if (moviesList) moviesList.innerHTML = '';
movies.forEach(movie => {
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 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 legacyUrl = cleanUrl(movie?.vod_link);
const hasLegacy = !!legacyUrl;
const movieId = movie && movie._id ? String(movie._id) : '';
const userRatingAvg = Number(movie?.user_rating_avg);
const userRatingCount = Number(movie?.user_rating_count) || 0;
const userRating = Number(movie?.user_rating);
const userRatingText =
Number.isFinite(userRatingAvg) && userRatingAvg > 0
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
: '';
const avgHtml = userRatingText
? ((window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: userRatingText })
: `User rating ${userRatingText}`)
: '';
const userRatingOwn =
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
const starsHtml = Array.from({ length: 5 }, (_, idx) => {
const value = idx + 1;
const isActive = Number.isFinite(userRating) && userRating >= value;
return `<button type="button" class="movie-star${isActive ? ' is-active' : ''}" data-value="${value}" aria-label="${value} étoile${value > 1 ? 's' : ''}" aria-pressed="${isActive ? 'true' : 'false'}">★</button>`;
}).join('');
// 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 ? legacyUrl : '');
const isClickable = !!playerUrl;
// Progress: only show for in-app player sources (not legacy vod_link, not "no source").
// Also avoid showing a meaningless "0:00" if they never really started.
let progressHtml = '';
if (hasNewPlayerSource && movieId) {
const p = progressByMovieId.get(movieId);
const time = Number(p?.time) || 0;
const duration = p?.duration === null || p?.duration === undefined ? null : Number(p.duration);
if (time > 1) {
if (duration && Number.isFinite(duration) && duration > 0) {
const pct = Math.max(0, Math.min(100, (time / duration) * 100));
// Hide "resume/progress" once we're basically at the credits.
// Backend uses 95% as the "credit roll" threshold too.
if (pct < 95) {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center text-muted small">
<span>${resumeText}</span>
<span>${pct.toFixed(0)}%</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar" role="progressbar" style="width: ${pct.toFixed(2)}%;" aria-valuenow="${pct.toFixed(0)}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
`;
}
} else {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2 text-muted small">
${resumeText}
</div>
`;
}
}
}
const camBadge = movie?.cam ? '<span class="badge bg-warning text-dark position-absolute top-0 end-0 m-2 cam-badge">CAM</span>' : '';
const watchedBadge = isChecked
? `<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.watched') : 'WATCHED'}</span>`
: '';
const noSourceHtml = !isClickable
? `<div class="text-muted small mb-1">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.noSource') : 'No source'}</div>`
: '';
const posterBlock = `
<div class="text-center pt-3 px-3 position-relative movie-poster-wrap">
${camBadge}
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
</div>
`;
const movieDiv = document.createElement('div');
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = `
<div class="card h-100 shadow-sm">
${watchedBadge}
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
${posterBlock}
</a>`
: `${posterBlock}`
}
<div class="card-body d-flex flex-column">
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<h5 class="card-title mb-1">
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
: `<span class="text-dark">${movie.title}</span>`
}
</h5>
${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
<div class="movie-rating-block mt-1">
<div class="movie-rating-sub movie-rating-avg${avgHtml ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${avgHtml ? avgHtml : ''}</div>
</div>
</div>
<div class="movie-imdb-wrap">
<div class="movie-imdb-block">
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'}">
<span class="imdb-icon" aria-hidden="true">IMDb</span>
</a>
</div>
<div class="movie-rating-stars" role="group" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('player.rateThisFilm') : 'Rate this film'}" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
${starsHtml}
</div>
</div>
</div>
${noSourceHtml}
<div class="movie-info-box">
<details class="movie-info-categories">
<summary class="movie-info-summary">
<span class="movie-info-summary-title">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.nominatedCategories') : 'Nominated categories'}</span>
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
</summary>
<div class="movie-info-category">${movie.category}</div>
</details>
<p class="movie-info-desc">${movie.description}</p>
</div>
</div>
<div class="mt-auto">
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.watchedOn') : 'Watched on:'}</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div>
</div>`;
moviesList.appendChild(movieDiv);
const starsWrap = movieDiv.querySelector('.movie-rating-stars');
if (starsWrap) {
if (!token || !movieId) {
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
} else {
starsWrap.addEventListener('click', async (event) => {
const target = event.target;
if (!target || !target.classList.contains('movie-star')) return;
const value = normalizeRatingValue(target.getAttribute('data-value'));
if (!value) return;
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
const current = Number(starsWrap.dataset.currentRating || '');
const shouldClear = current && current === value;
const result = shouldClear
? await deleteUserRating(movieId, token)
: await saveUserRating(movieId, value, token);
if (result) {
updateStarUi(starsWrap, result?.userRating);
updateRatingTexts(movieDiv, result?.averageRating, result?.ratingsCount, result?.userRating);
}
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.removeAttribute('disabled'));
});
}
}
});
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(80);
await waitForImagesIn(moviesList, (loaded, total) => {
// Move 80% -> 98% as posters resolve
const pct = 80 + (loaded / Math.max(1, total)) * 18;
pageLoader.setProgress(pct);
});
pageLoader.done();
} catch (e) {
setLoadError(moviesList, t('movies.errorLoadingList', 'Unable to load movie list. Try again in a moment.'));
pageLoader.fail(t('movies.errorLoading', 'Unable to load movies. Check your connection and try again.'));
}
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
const filterSwitch = document.getElementById('flexSwitchCheckDefault');
const savedFilterState = localStorage.getItem('filter-checkbox');
const isFilterActive = savedFilterState === 'checked';
filterSwitch.checked = isFilterActive;
filterSwitch.addEventListener('change', function () {
const filterState = filterSwitch.checked ? 'checked' : 'unchecked';
localStorage.setItem('filter-checkbox', filterState);
filterMovies();
});
function filterMovies() {
const showUnwatchedOnly = filterSwitch.checked;
const movieCards = document.querySelectorAll('.movie-card');
movieCards.forEach(card => {
const imdbId = card.getAttribute('data-imdb-id');
const isWatched = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === imdbId);
if (showUnwatchedOnly && isWatched) {
card.style.display = 'none';
} else {
card.style.display = 'block';
}
});
}
filterMovies();
});