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 () {