add video backend server, fix styling and more #54
+76
-21
@@ -1,6 +1,7 @@
|
||||
window.onload = async function () {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const movieTableBody = document.getElementById('movie-table-body');
|
||||
const watchedRatioEl = document.getElementById('watched-ratio');
|
||||
|
||||
function showTableLoading() {
|
||||
if (!movieTableBody) return;
|
||||
@@ -18,6 +19,18 @@ window.onload = async function () {
|
||||
`;
|
||||
}
|
||||
|
||||
function setInlineLoading(el, label = 'Chargement…') {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<span class="d-inline-flex align-items-center gap-2" aria-live="polite" aria-busy="true">
|
||||
<span class="spinner-border spinner-border-sm text-secondary" role="status" aria-label="Chargement">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</span>
|
||||
<span>${label}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function showTableError(message) {
|
||||
if (!movieTableBody) return;
|
||||
movieTableBody.innerHTML = `
|
||||
@@ -135,29 +148,52 @@ window.onload = async function () {
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
async function fetchMoviesSummary(year) {
|
||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
const activeYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars ${activeYear} - Checklist`;
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
|
||||
// Render countdown immediately with a deterministic fallback (then refine from settings).
|
||||
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
|
||||
const timeLeftEl = document.getElementById('time-left');
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
||||
let timeDifference = fallbackDate - currentDate;
|
||||
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
fallbackDate;
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
const now = new Date();
|
||||
timeDifference = targetDate - now;
|
||||
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}).catch(() => {});
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
@@ -186,22 +222,35 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
showTableLoading();
|
||||
setInlineLoading(watchedRatioEl);
|
||||
const progressBarEl = document.getElementById('progress-bar');
|
||||
if (progressBarEl) {
|
||||
progressBarEl.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
|
||||
let movies = [];
|
||||
let watchedMovies = [];
|
||||
let watchedMoviesInYear = [];
|
||||
let movieImdbIds = new Set();
|
||||
try {
|
||||
// Fetch remaining data in parallel (Render latency is high).
|
||||
const [completionModalContent, moviesRes, watchedRes] = await Promise.all([
|
||||
// Start summary fetch immediately so the header can update without waiting for the full list.
|
||||
const summaryPromise = fetchMoviesSummary(activeYear)
|
||||
.then((summary) => {
|
||||
const totalCount = Number(summary?.totalMoviesCount) || 0;
|
||||
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
|
||||
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
|
||||
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
|
||||
updateProgressBar(watchedCount, totalCount, false);
|
||||
return summary;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Fetch remaining data in parallel.
|
||||
const [completionModalContent, moviesRes] = await Promise.all([
|
||||
fetchCompletionModalContent(),
|
||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}),
|
||||
fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -218,8 +267,11 @@ window.onload = async function () {
|
||||
// Sort movies alphabetically by title
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
const summary = await summaryPromise;
|
||||
watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
|
||||
|
||||
if (progressBarEl) {
|
||||
progressBarEl.classList.remove('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
} catch (e) {
|
||||
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
|
||||
@@ -259,10 +311,12 @@ window.onload = async function () {
|
||||
frame();
|
||||
}
|
||||
|
||||
function updateProgressBar(watchedCount, totalCount) {
|
||||
function updateProgressBar(watchedCount, totalCount, celebrate = true) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const percentage = (watchedCount / totalCount) * 100;
|
||||
console.log(percentage);
|
||||
if (!progressBar) return;
|
||||
const safeTotal = Number(totalCount);
|
||||
const safeWatched = Number(watchedCount);
|
||||
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
|
||||
if (percentage <= 50) {
|
||||
@@ -273,8 +327,8 @@ window.onload = async function () {
|
||||
|
||||
progressBar.setAttribute('aria-valuenow', Math.round(percentage));
|
||||
|
||||
if (celebrate) {
|
||||
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
|
||||
|
||||
if (percentage === 100) {
|
||||
videoModal.show();
|
||||
launchConfetti();
|
||||
@@ -285,6 +339,7 @@ window.onload = async function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar(watchedMoviesCount, totalMoviesCount);
|
||||
|
||||
|
||||
+89
-45
@@ -10,6 +10,18 @@ window.onload = async function () {
|
||||
`;
|
||||
}
|
||||
|
||||
function setInlineLoading(el, label = 'Chargement…') {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<span class="d-inline-flex align-items-center gap-2" aria-live="polite" aria-busy="true">
|
||||
<span class="spinner-border spinner-border-sm text-secondary" role="status" aria-label="Chargement">
|
||||
<span class="visually-hidden">Chargement...</span>
|
||||
</span>
|
||||
<span>${label}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function setLoadError(el, message) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
@@ -49,28 +61,37 @@ window.onload = async function () {
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
async function fetchMoviesLastUpdated(year, token) {
|
||||
const el = document.getElementById('movies-last-updated');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/movies/last-update?year=${encodeURIComponent(String(year))}`, {
|
||||
async function fetchMoviesSummary(year, token) {
|
||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const lastUpdatedIso = data?.lastUpdated;
|
||||
if (!lastUpdatedIso) return;
|
||||
|
||||
const d = new Date(lastUpdatedIso);
|
||||
if (Number.isNaN(d.getTime())) return;
|
||||
|
||||
el.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch (_) {
|
||||
// Keep the default placeholder (—)
|
||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function applySummaryToHeader(summary) {
|
||||
const lastUpdatedEl = document.getElementById('movies-last-updated');
|
||||
const ratioEl = document.getElementById('watched-ratio');
|
||||
|
||||
const lastUpdatedIso = summary?.lastUpdated;
|
||||
if (lastUpdatedEl) {
|
||||
if (lastUpdatedIso) {
|
||||
const d = new Date(lastUpdatedIso);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} else {
|
||||
lastUpdatedEl.textContent = '—';
|
||||
}
|
||||
} else {
|
||||
lastUpdatedEl.textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
const total = Number(summary?.totalMoviesCount) || 0;
|
||||
const watched = Number(summary?.watchedMoviesCount) || 0;
|
||||
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
|
||||
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
|
||||
}
|
||||
|
||||
const activeYear = await fetchActiveYear();
|
||||
@@ -78,24 +99,41 @@ window.onload = async function () {
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
|
||||
// Render countdown immediately with a deterministic fallback (then refine from settings).
|
||||
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
|
||||
const timeLeftEl = document.getElementById('time-left');
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = fallbackDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
fallbackDate;
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
@@ -128,21 +166,33 @@ window.onload = async function () {
|
||||
const moviesList = document.getElementById('movies-list');
|
||||
setLoading(moviesList);
|
||||
|
||||
// Populate "Dernière mise à jour des films" banner (per active year)
|
||||
await fetchMoviesLastUpdated(activeYear, token);
|
||||
// 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 = [];
|
||||
|
||||
try {
|
||||
// Fetch movies + watched list in parallel (Render latency is high).
|
||||
const [res, watchedRes] = await Promise.all([
|
||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}),
|
||||
fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
// Start summary fetch immediately so header can update ASAP.
|
||||
const summaryPromise = fetchMoviesSummary(activeYear, token)
|
||||
.then((data) => {
|
||||
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
|
||||
applySummaryToHeader(data);
|
||||
return data;
|
||||
})
|
||||
]);
|
||||
.catch(() => {
|
||||
// Leave the placeholders (—) if summary fails; movies list can still load.
|
||||
const lastUpdatedEl = document.getElementById('movies-last-updated');
|
||||
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
|
||||
const ratioEl = document.getElementById('watched-ratio');
|
||||
if (ratioEl) ratioEl.textContent = '—';
|
||||
return null;
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -151,18 +201,12 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
const movies = await res.json();
|
||||
// If summary is still in flight, wait for it before rendering watched banners.
|
||||
await summaryPromise;
|
||||
|
||||
// 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' }));
|
||||
|
||||
let watchedMovies = [];
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
}
|
||||
|
||||
const movieImdbIds = new Set(movies.map((m) => m.imdb_id).filter(Boolean));
|
||||
const watchedMoviesInYear = watchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
|
||||
|
||||
const totalMoviesCount = movies.length;
|
||||
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
|
||||
@@ -195,6 +195,74 @@ router.get("/last-update", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get lightweight per-year summary for fast UI (counts, watched-in-year, last update)
|
||||
router.get("/summary", async (req, res) => {
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
if (!token) return res.status(401).json({ message: "Token is required" });
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const year = parseOscarYear(req.query.year);
|
||||
const filter = year ? { year } : {};
|
||||
|
||||
// Base (year-wide) data can be cached (not user-specific).
|
||||
const cacheKey = `movies:summary-base:${year || "all"}`;
|
||||
let base = cacheGet(cacheKey);
|
||||
|
||||
if (!base) {
|
||||
const [idDocs, latestMovie] = await Promise.all([
|
||||
Movie.find(filter).select("imdb_id").lean(),
|
||||
Movie.findOne(filter)
|
||||
.select("updatedAt createdAt")
|
||||
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
|
||||
.lean(),
|
||||
]);
|
||||
|
||||
const imdbIds = idDocs.map((d) => d.imdb_id).filter(Boolean);
|
||||
const imdbIdSet = new Set(imdbIds);
|
||||
|
||||
const lastUpdated =
|
||||
latestMovie?.updatedAt ||
|
||||
latestMovie?.createdAt ||
|
||||
(typeof latestMovie?._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
|
||||
|
||||
base = {
|
||||
year: year || null,
|
||||
totalMoviesCount: imdbIds.length,
|
||||
imdbIds,
|
||||
// Store ISO string so it's JSON-safe + stable
|
||||
lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null,
|
||||
};
|
||||
|
||||
// Avoid storing a Set in cache (non-serializable and not needed elsewhere)
|
||||
cacheSet(cacheKey, base);
|
||||
}
|
||||
|
||||
const user = await User.findById(decoded.id).select("watchedMovies").lean();
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
const imdbIdSet = new Set(base.imdbIds || []);
|
||||
const watchedMoviesAll = Array.isArray(user.watchedMovies) ? user.watchedMovies : [];
|
||||
const watchedMoviesInYear = watchedMoviesAll.filter((wm) => imdbIdSet.has(wm.imdb_id));
|
||||
|
||||
// Since this is user-specific, don't encourage shared caching.
|
||||
res.set("Cache-Control", "private, max-age=5");
|
||||
return res.status(200).json({
|
||||
year: base.year,
|
||||
totalMoviesCount: base.totalMoviesCount,
|
||||
watchedMoviesCount: watchedMoviesInYear.length,
|
||||
watchedMovies: watchedMoviesInYear,
|
||||
lastUpdated: base.lastUpdated,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
return res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
return res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get watched movies for the user
|
||||
router.get("/watchedMovies", async (req, res) => {
|
||||
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
|
||||
|
||||
Reference in New Issue
Block a user