Merge pull request #37 from quoije/cursor/page-load-indicators-ea0b
Page load indicators
This commit was merged in pull request #37.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+104
-25
@@ -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 = `
|
||||
<div class="page-loader-card">
|
||||
<p class="page-loader-title">${title}</p>
|
||||
<p class="page-loader-subtitle" id="page-loader-subtitle">${subtitle}</p>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
<div class="page-loader-actions d-none" id="page-loader-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-dark" id="page-loader-retry">Réessayer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4" aria-live="polite" aria-busy="true">
|
||||
<div class="d-inline-flex align-items-center gap-2" role="status" aria-label="Chargement">
|
||||
<span class="text-muted">Chargement</span>
|
||||
<span class="loading-dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = '/';
|
||||
|
||||
+133
-21
@@ -1,21 +1,123 @@
|
||||
window.onload = async function () {
|
||||
function setLoading(el) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="loading-indicator" role="status" aria-live="polite" aria-busy="true" aria-label="Chargement">
|
||||
<span class="text-muted">Chargement</span>
|
||||
<span class="loading-dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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 = `
|
||||
<div class="page-loader-card">
|
||||
<p class="page-loader-title">${title}</p>
|
||||
<p class="page-loader-subtitle" id="page-loader-subtitle">${subtitle}</p>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
<div class="page-loader-actions d-none" id="page-loader-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-dark" id="page-loader-retry">Réessayer</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 () {
|
||||
</div>`;
|
||||
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 () {
|
||||
|
||||
Reference in New Issue
Block a user