From fa0ba7468214ae89091f7b2988996a50a216e16c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 21:50:24 +0000 Subject: [PATCH] feat: Implement full-page loader with progress bar Co-authored-by: mathieumoran1 --- public/css/app.css | 69 +++++++++++++++++ public/js/app_checklist.js | 129 +++++++++++++++++++++++++------ public/js/app_movies.js | 154 ++++++++++++++++++++++++++++++++----- 3 files changed, 306 insertions(+), 46 deletions(-) diff --git a/public/css/app.css b/public/css/app.css index 46b255d..ee75ea4 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -49,4 +49,73 @@ footer { @keyframes loading-dot { 0%, 80%, 100% { opacity: 0.2; } 40% { opacity: 1; } + } + + /* Full-page loader overlay (blur + progress bar) */ + body.page-loading { + overflow: hidden; + } + + .page-loader-overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(255, 255, 255, 0.55); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: opacity 200ms ease; + opacity: 1; + } + + .page-loader-overlay.page-loader-hide { + opacity: 0; + pointer-events: none; + } + + .page-loader-card { + width: min(520px, 92vw); + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 16px; + padding: 18px 18px 14px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); + } + + .page-loader-title { + margin: 0 0 10px 0; + font-weight: 700; + font-size: 1rem; + color: #111; + } + + .page-loader-subtitle { + margin: 0 0 12px 0; + font-size: 0.9rem; + color: rgba(0, 0, 0, 0.7); + } + + .page-loader-progress { + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(0, 0, 0, 0.08); + } + + .page-loader-bar { + height: 100%; + width: 0%; + border-radius: 999px; + background: linear-gradient(90deg, #ff4d4d 0%, #ffb200 50%, #4cd964 100%); + transition: width 220ms ease; + } + + .page-loader-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 10px; } \ No newline at end of file diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index de2935f..e72ae74 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -1,30 +1,100 @@ +function createPageLoader(options = {}) { + const title = String(options.title || 'Chargement…'); + const subtitle = String(options.subtitle || 'Préparation de la page…'); + + let progress = 0; + let removed = false; + + const overlay = document.createElement('div'); + overlay.className = 'page-loader-overlay'; + overlay.setAttribute('role', 'status'); + overlay.setAttribute('aria-live', 'polite'); + overlay.setAttribute('aria-busy', 'true'); + + overlay.innerHTML = ` +
+

${title}

+

${subtitle}

+ +
+ +
+
+ `; + + const barEl = () => overlay.querySelector('#page-loader-bar'); + const subtitleEl = () => overlay.querySelector('#page-loader-subtitle'); + const actionsEl = () => overlay.querySelector('#page-loader-actions'); + const retryBtnEl = () => overlay.querySelector('#page-loader-retry'); + + 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(text) { + const el = subtitleEl(); + if (el) el.textContent = String(text || ''); + } + + function ensureMounted() { + if (removed) return; + if (!overlay.isConnected) { + document.body.classList.add('page-loading'); + document.body.appendChild(overlay); + } + } + + 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; + try { overlay.remove(); } catch (_) {} + }, 220); + } + + function done() { + setProgress(100); + hideAndRemoveSoon(); + } + + function fail(message) { + setSubtitle(message || 'Erreur lors du chargement.'); + setProgress(Math.max(progress, 90)); + const actions = actionsEl(); + if (actions) actions.classList.remove('d-none'); + const btn = retryBtnEl(); + if (btn) btn.onclick = () => window.location.reload(); + } + + ensureMounted(); + setProgress(8); + + return { setProgress, setSubtitle, done, fail }; +} + window.onload = async function () { + const pageLoader = createPageLoader({ + title: 'Chargement de la checklist', + subtitle: 'Récupération des données…' + }); + const token = localStorage.getItem('auth_token'); const movieTableBody = document.getElementById('movie-table-body'); const watchedRatioEl = document.getElementById('watched-ratio'); - function showTableLoading() { - if (!movieTableBody) return; - movieTableBody.innerHTML = ` - - -
- Chargement - -
- - - `; - } - - function setInlineLoading(el, label = 'Chargement…') { - // Keep the header clean: no inline spinners, just text. - if (!el) return; - el.textContent = label; - el.setAttribute('aria-live', 'polite'); - el.setAttribute('aria-busy', 'true'); - } - function showTableError(message) { if (!movieTableBody) return; movieTableBody.innerHTML = ` @@ -215,10 +285,9 @@ window.onload = async function () { window.location.href = '/'; } - showTableLoading(); - setInlineLoading(watchedRatioEl); + if (watchedRatioEl) watchedRatioEl.textContent = '—'; const progressBarEl = document.getElementById('progress-bar'); - // Avoid extra "loading" animations; the table loader is enough. + pageLoader.setProgress(18); let movies = []; let watchedMovies = []; @@ -233,11 +302,14 @@ window.onload = async function () { const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0'; if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`; updateProgressBar(watchedCount, totalCount, false); + pageLoader.setProgress(32); return summary; }) .catch(() => null); // Fetch remaining data in parallel. + pageLoader.setSubtitle('Chargement de la liste des films…'); + pageLoader.setProgress(26); const [completionModalContent, moviesRes] = await Promise.all([ fetchCompletionModalContent(), fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, { @@ -247,6 +319,7 @@ window.onload = async function () { ]); applyCompletionModalContent(completionModalContent); + pageLoader.setProgress(55); if (!moviesRes.ok) { localStorage.removeItem('auth_token'); @@ -255,6 +328,7 @@ window.onload = async function () { } movies = await moviesRes.json(); + pageLoader.setProgress(62); // Sort movies alphabetically by title movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); @@ -265,6 +339,7 @@ window.onload = async function () { if (progressBarEl) progressBarEl.removeAttribute('aria-busy'); } catch (e) { showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.'); + pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.'); return; } @@ -411,6 +486,10 @@ window.onload = async function () { const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; + pageLoader.setSubtitle('Finalisation de l’affichage…'); + pageLoader.setProgress(92); + pageLoader.done(); + document.getElementById('log-off').addEventListener('click', () => { localStorage.removeItem('auth_token'); window.location.href = '/'; diff --git a/public/js/app_movies.js b/public/js/app_movies.js index fec4bc6..18b3cb0 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -1,21 +1,123 @@ -window.onload = async function () { - function setLoading(el) { - if (!el) return; - el.innerHTML = ` -
- Chargement - -
- `; - } +function createPageLoader(options = {}) { + const title = String(options.title || 'Chargement…'); + const subtitle = String(options.subtitle || 'Préparation de la page…'); - function setInlineLoading(el, label = 'Chargement…') { - // Keep the header clean: no inline spinners, just text. - if (!el) return; - el.textContent = label; - el.setAttribute('aria-live', 'polite'); - el.setAttribute('aria-busy', 'true'); + let progress = 0; + let removed = false; + + const overlay = document.createElement('div'); + overlay.className = 'page-loader-overlay'; + overlay.setAttribute('role', 'status'); + overlay.setAttribute('aria-live', 'polite'); + overlay.setAttribute('aria-busy', 'true'); + + overlay.innerHTML = ` +
+

${title}

+

${subtitle}

+ +
+ +
+
+ `; + + const barEl = () => overlay.querySelector('#page-loader-bar'); + const subtitleEl = () => overlay.querySelector('#page-loader-subtitle'); + const actionsEl = () => overlay.querySelector('#page-loader-actions'); + const retryBtnEl = () => overlay.querySelector('#page-loader-retry'); + + 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(text) { + const el = subtitleEl(); + if (el) el.textContent = String(text || ''); + } + + function ensureMounted() { + if (removed) return; + if (!overlay.isConnected) { + document.body.classList.add('page-loading'); + document.body.appendChild(overlay); } + } + + 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; + try { overlay.remove(); } catch (_) {} + }, 220); + } + + function done() { + setProgress(100); + hideAndRemoveSoon(); + } + + function fail(message) { + setSubtitle(message || 'Erreur lors du chargement.'); + setProgress(Math.max(progress, 90)); + const actions = actionsEl(); + if (actions) actions.classList.remove('d-none'); + const btn = retryBtnEl(); + if (btn) btn.onclick = () => window.location.reload(); + } + + 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.onload = async function () { + const pageLoader = createPageLoader({ + title: 'Chargement des films', + subtitle: 'Récupération des données…' + }); function setLoadError(el, message) { if (!el) return; @@ -159,11 +261,7 @@ window.onload = async function () { } const moviesList = document.getElementById('movies-list'); - setLoading(moviesList); - - // Make header stats feel responsive (these used to wait for the heavy movies payload). - setInlineLoading(document.getElementById('movies-last-updated')); - setInlineLoading(document.getElementById('watched-ratio')); + pageLoader.setProgress(18); let watchedMoviesInYear = []; @@ -173,6 +271,7 @@ window.onload = async function () { .then((data) => { watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : []; applySummaryToHeader(data); + pageLoader.setProgress(32); return data; }) .catch(() => { @@ -184,6 +283,7 @@ window.onload = async function () { return null; }); + pageLoader.setProgress(28); const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` }, @@ -196,8 +296,10 @@ window.onload = async function () { } const movies = await res.json(); + pageLoader.setProgress(55); // If summary is still in flight, wait for it before rendering watched banners. await summaryPromise; + 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' })); @@ -245,8 +347,18 @@ window.onload = async function () { `; moviesList.appendChild(movieDiv); }); + + 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, 'Impossible de charger la liste des films. Réessaie dans quelques instants.'); + pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.'); } document.getElementById('log-off').addEventListener('click', function () { -- 2.54.0