Refactor page loader to use a dedicated JS class

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-21 22:04:58 +00:00
co-authored by mathieumoran1
parent 6d7269f331
commit 1bc65ebd77
4 changed files with 169 additions and 62 deletions
+9 -5
View File
@@ -63,12 +63,16 @@ function createPageLoader(options = {}) {
function ensureMounted() { function ensureMounted() {
if (removed) return; if (removed) return;
if (!overlay.isConnected) { // IMPORTANT: even if the overlay is preloaded in HTML (already connected),
updateOverlayTopOffset(); // we still need to compute the navbar offset. Otherwise it defaults to 0px
document.body.classList.add('page-loading'); // and the blur covers the header.
document.body.appendChild(overlay); updateOverlayTopOffset();
document.body.classList.add('page-loading');
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.) 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'); const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') { if (nav && typeof ResizeObserver !== 'undefined') {
try { try {
+9 -5
View File
@@ -63,12 +63,16 @@ function createPageLoader(options = {}) {
function ensureMounted() { function ensureMounted() {
if (removed) return; if (removed) return;
if (!overlay.isConnected) { // IMPORTANT: even if the overlay is preloaded in HTML (already connected),
updateOverlayTopOffset(); // we still need to compute the navbar offset. Otherwise it defaults to 0px
document.body.classList.add('page-loading'); // and the blur covers the header.
document.body.appendChild(overlay); updateOverlayTopOffset();
document.body.classList.add('page-loading');
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.) 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'); const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') { if (nav && typeof ResizeObserver !== 'undefined') {
try { try {
+137 -28
View File
@@ -1,26 +1,133 @@
window.onload = async function () { function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la 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 };
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des statistiques',
subtitle: 'Récupération des données…'
});
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
let winnersCache = null; let winnersCache = null;
let completionsCache = null; let completionsCache = null;
function setTableBodyLoading(tbodyId, colspan) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
const span = Number.isFinite(Number(colspan)) ? Number(colspan) : 1;
tbody.innerHTML = `
<tr>
<td colspan="${span}" class="text-center py-4" aria-live="polite" aria-busy="true">
<div class="d-inline-flex align-items-center gap-2">
<div class="spinner-border text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</div>
<span class="text-muted">Chargement…</span>
</div>
</td>
</tr>
`;
}
async function fetchActiveYear() { async function fetchActiveYear() {
try { try {
const res = await fetch('/api/settings/year', { method: 'GET' }); const res = await fetch('/api/settings/year', { method: 'GET' });
@@ -247,16 +354,15 @@ window.onload = async function () {
} }
try { try {
pageLoader.setProgress(12);
// Show loading states immediately (avoid "blank" panels) // Show loading states immediately (avoid "blank" panels)
const table = document.querySelector('.user-table'); const table = document.querySelector('.user-table');
const spinner = document.getElementById('loading-spinner');
const statsError = document.getElementById('stats-error'); const statsError = document.getElementById('stats-error');
if (spinner) spinner.classList.remove('d-none');
if (table) table.classList.add('d-none'); if (table) table.classList.add('d-none');
if (statsError) statsError.classList.add('d-none'); if (statsError) statsError.classList.add('d-none');
setTableBodyLoading('winners-table-body', 2);
setTableBodyLoading('completers-table-body', 4);
pageLoader.setProgress(20);
const activeYear = await fetchActiveYear(); const activeYear = await fetchActiveYear();
if (activeYear) { if (activeYear) {
document.title = `Pool Oscars ${activeYear} - Statistiques des utilisateurs`; document.title = `Pool Oscars ${activeYear} - Statistiques des utilisateurs`;
@@ -265,6 +371,7 @@ window.onload = async function () {
} }
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats'; const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
pageLoader.setProgress(32);
const [statsRes, winners, completions] = await Promise.all([ const [statsRes, winners, completions] = await Promise.all([
fetch(statsUrl, { fetch(statsUrl, {
method: 'GET', method: 'GET',
@@ -283,6 +390,7 @@ window.onload = async function () {
throw new Error('Failed to fetch user stats'); throw new Error('Failed to fetch user stats');
} }
pageLoader.setProgress(70);
const stats = await statsRes.json(); const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body'); const userTableBody = document.getElementById('user-table-body');
@@ -304,10 +412,10 @@ window.onload = async function () {
userTableBody.appendChild(userRow); userTableBody.appendChild(userRow);
}); });
// Hide spinner and show table after loading // Show table after loading
if (spinner) spinner.classList.add('d-none');
if (table) table.classList.remove('d-none'); if (table) table.classList.remove('d-none');
if (statsError) statsError.classList.add('d-none'); if (statsError) statsError.classList.add('d-none');
pageLoader.setProgress(92);
// Add event listener for user links // Add event listener for user links
document.querySelectorAll('.user-link').forEach(link => { document.querySelectorAll('.user-link').forEach(link => {
@@ -334,12 +442,12 @@ window.onload = async function () {
modal.show(); modal.show();
}); });
}); });
pageLoader.done();
} catch (error) { } catch (error) {
console.error('Error loading user statistics:', error); console.error('Error loading user statistics:', error);
const spinner = document.getElementById('loading-spinner');
const table = document.querySelector('.user-table'); const table = document.querySelector('.user-table');
const statsError = document.getElementById('stats-error'); const statsError = document.getElementById('stats-error');
if (spinner) spinner.classList.add('d-none');
if (table) table.classList.add('d-none'); if (table) table.classList.add('d-none');
if (statsError) { if (statsError) {
statsError.textContent = "Impossible de charger le classement pour le moment."; statsError.textContent = "Impossible de charger le classement pour le moment.";
@@ -354,6 +462,7 @@ window.onload = async function () {
if (completionsCache === null) completionsCache = await fetchCompletions(); if (completionsCache === null) completionsCache = await fetchCompletions();
renderCompletions(completionsCache); renderCompletions(completionsCache);
} catch (_) {} } catch (_) {}
pageLoader.fail();
} }
// Log-off functionality // Log-off functionality
@@ -361,4 +470,4 @@ window.onload = async function () {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/'; window.location.href = '/';
}); });
}; });
+14 -24
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Statistiques des utilisateurs</title> <title>Pool Oscars - Statistiques des utilisateurs</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style> <style>
.navbar-custom { .navbar-custom {
background-color: black; background-color: black;
@@ -36,7 +37,17 @@
} }
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info --> <!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom"> <nav class="navbar navbar-expand-lg navbar-custom">
@@ -90,13 +101,6 @@
<div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0"> <div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0">
<div id="stats-error" class="alert alert-danger d-none" role="alert"></div> <div id="stats-error" class="alert alert-danger d-none" role="alert"></div>
<!-- Spinner shown while loading -->
<div id="loading-spinner" class="d-flex justify-content-center align-items-center my-5" aria-live="polite" aria-busy="true">
<div class="spinner-border text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<!-- Table for users and movies watched --> <!-- Table for users and movies watched -->
<table class="table table-striped table-hover align-middle user-table d-none"> <table class="table table-striped table-hover align-middle user-table d-none">
<thead> <thead>
@@ -125,14 +129,7 @@
</thead> </thead>
<tbody id="winners-table-body"> <tbody id="winners-table-body">
<tr> <tr>
<td colspan="2" class="text-center py-4" aria-live="polite" aria-busy="true"> <td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
<div class="d-inline-flex align-items-center gap-2">
<div class="spinner-border text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</div>
<span class="text-muted">Chargement…</span>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -155,14 +152,7 @@
</thead> </thead>
<tbody id="completers-table-body"> <tbody id="completers-table-body">
<tr> <tr>
<td colspan="4" class="text-center py-4" aria-live="polite" aria-busy="true"> <td colspan="4" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
<div class="d-inline-flex align-items-center gap-2">
<div class="spinner-border text-secondary" role="status" aria-label="Chargement">
<span class="visually-hidden">Chargement...</span>
</div>
<span class="text-muted">Chargement…</span>
</div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>