add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
3 changed files with 306 additions and 46 deletions
Showing only changes of commit 74c833b62c - Show all commits
+69
View File
@@ -49,4 +49,73 @@ footer {
@keyframes loading-dot { @keyframes loading-dot {
0%, 80%, 100% { opacity: 0.2; } 0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; } 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
View File
@@ -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 () { 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 token = localStorage.getItem('auth_token');
const movieTableBody = document.getElementById('movie-table-body'); const movieTableBody = document.getElementById('movie-table-body');
const watchedRatioEl = document.getElementById('watched-ratio'); 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) { function showTableError(message) {
if (!movieTableBody) return; if (!movieTableBody) return;
movieTableBody.innerHTML = ` movieTableBody.innerHTML = `
@@ -215,10 +285,9 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
} }
showTableLoading(); if (watchedRatioEl) watchedRatioEl.textContent = '—';
setInlineLoading(watchedRatioEl);
const progressBarEl = document.getElementById('progress-bar'); const progressBarEl = document.getElementById('progress-bar');
// Avoid extra "loading" animations; the table loader is enough. pageLoader.setProgress(18);
let movies = []; let movies = [];
let watchedMovies = []; let watchedMovies = [];
@@ -233,11 +302,14 @@ window.onload = async function () {
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0'; const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`; if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
updateProgressBar(watchedCount, totalCount, false); updateProgressBar(watchedCount, totalCount, false);
pageLoader.setProgress(32);
return summary; return summary;
}) })
.catch(() => null); .catch(() => null);
// Fetch remaining data in parallel. // Fetch remaining data in parallel.
pageLoader.setSubtitle('Chargement de la liste des films…');
pageLoader.setProgress(26);
const [completionModalContent, moviesRes] = await Promise.all([ const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(), fetchCompletionModalContent(),
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, { fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
@@ -247,6 +319,7 @@ window.onload = async function () {
]); ]);
applyCompletionModalContent(completionModalContent); applyCompletionModalContent(completionModalContent);
pageLoader.setProgress(55);
if (!moviesRes.ok) { if (!moviesRes.ok) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -255,6 +328,7 @@ window.onload = async function () {
} }
movies = await moviesRes.json(); movies = await moviesRes.json();
pageLoader.setProgress(62);
// Sort movies alphabetically by title // Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); 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'); if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
} catch (e) { } catch (e) {
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.'); 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; return;
} }
@@ -411,6 +486,10 @@ window.onload = async function () {
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; 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', () => { document.getElementById('log-off').addEventListener('click', () => {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/'; window.location.href = '/';
+133 -21
View File
@@ -1,21 +1,123 @@
window.onload = async function () { function createPageLoader(options = {}) {
function setLoading(el) { const title = String(options.title || 'Chargement…');
if (!el) return; const subtitle = String(options.subtitle || 'Préparation de la page…');
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 setInlineLoading(el, label = 'Chargement…') { let progress = 0;
// Keep the header clean: no inline spinners, just text. let removed = false;
if (!el) return;
el.textContent = label; const overlay = document.createElement('div');
el.setAttribute('aria-live', 'polite'); overlay.className = 'page-loader-overlay';
el.setAttribute('aria-busy', 'true'); 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) { function setLoadError(el, message) {
if (!el) return; if (!el) return;
@@ -159,11 +261,7 @@ window.onload = async function () {
} }
const moviesList = document.getElementById('movies-list'); const moviesList = document.getElementById('movies-list');
setLoading(moviesList); pageLoader.setProgress(18);
// 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'));
let watchedMoviesInYear = []; let watchedMoviesInYear = [];
@@ -173,6 +271,7 @@ window.onload = async function () {
.then((data) => { .then((data) => {
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : []; watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
applySummaryToHeader(data); applySummaryToHeader(data);
pageLoader.setProgress(32);
return data; return data;
}) })
.catch(() => { .catch(() => {
@@ -184,6 +283,7 @@ window.onload = async function () {
return null; return null;
}); });
pageLoader.setProgress(28);
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, { const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
@@ -196,8 +296,10 @@ window.onload = async function () {
} }
const movies = await res.json(); const movies = await res.json();
pageLoader.setProgress(55);
// If summary is still in flight, wait for it before rendering watched banners. // If summary is still in flight, wait for it before rendering watched banners.
await summaryPromise; await summaryPromise;
pageLoader.setProgress(62);
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback) // 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' })); movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
@@ -245,8 +347,18 @@ window.onload = async function () {
</div>`; </div>`;
moviesList.appendChild(movieDiv); 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) { } catch (e) {
setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.'); 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 () { document.getElementById('log-off').addEventListener('click', function () {