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
7 changed files with 440 additions and 4 deletions
Showing only changes of commit e326ead817 - Show all commits
+1 -1
View File
@@ -79,7 +79,7 @@
<div class="container mt-5"> <div class="container mt-5">
<h2>Checklist</h2> <h2>Checklist</h2>
<div id="watched-ratio" class="ratio"></div> <div id="watched-ratio" class="ratio"></div>
<h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (15 mars <span id="oscar-year">—</span>) - <span id="time-left"></span></h3> <h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) - <span id="time-left"></span></h3>
<h3 class="fs-6"><span id="movies-per-day"></span></h3> <h3 class="fs-6"><span id="movies-per-day"></span></h3>
<div class="progress my-3"> <div class="progress my-3">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div> <div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
+32
View File
@@ -111,6 +111,38 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-xl-8">
<div class="card p-3">
<h4 class="mb-1">Date des Oscars (par année)</h4>
<p class="text-muted mb-3">
Configure la date exacte de la cérémonie pour le <strong>compte à rebours</strong> sur Films &amp; Checklist.
Si non défini, la date par défaut reste <code>15 mars</code> (comportement actuel).
</p>
<div class="row g-3">
<div class="col-md-4">
<label for="oscar_date_year" class="form-label">Année</label>
<select class="form-select" id="oscar_date_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-md-4">
<label for="oscar_date_value" class="form-label">Date</label>
<input type="date" class="form-control" id="oscar_date_value">
<div class="form-text">Format: YYYY-MM-DD.</div>
</div>
<div class="col-md-4 d-flex align-items-end justify-content-end gap-2">
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger">Effacer</button>
<button type="button" id="save-oscar-date" class="btn btn-warning">Enregistrer</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Dates configurées</div>
<div id="oscar-date-current" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
+31 -1
View File
@@ -88,6 +88,23 @@ window.onload = async function () {
} }
} }
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
const activeYear = await fetchActiveYear(); const activeYear = await fetchActiveYear();
document.title = `Pool Oscars ${activeYear} - Checklist`; document.title = `Pool Oscars ${activeYear} - Checklist`;
const oscarYearEl = document.getElementById('oscar-year'); const oscarYearEl = document.getElementById('oscar-year');
@@ -97,7 +114,20 @@ window.onload = async function () {
const completionModalContent = await fetchCompletionModalContent(); const completionModalContent = await fetchCompletionModalContent();
applyCompletionModalContent(completionModalContent); applyCompletionModalContent(completionModalContent);
const targetDate = new Date(`March 15, ${activeYear}`); const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
new Date(`March 15, ${activeYear} 12:00:00`);
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
}
}
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
+207
View File
@@ -41,6 +41,13 @@ window.onload = async function () {
const activeYearInput = document.getElementById('active_oscar_year'); const activeYearInput = document.getElementById('active_oscar_year');
const saveActiveYearBtn = document.getElementById('save-active-year'); const saveActiveYearBtn = document.getElementById('save-active-year');
// Oscar date per year (settings tab)
const oscarDateYearSelect = document.getElementById('oscar_date_year');
const oscarDateValueInput = document.getElementById('oscar_date_value');
const saveOscarDateBtn = document.getElementById('save-oscar-date');
const clearOscarDateBtn = document.getElementById('clear-oscar-date');
const oscarDateCurrentEl = document.getElementById('oscar-date-current');
// Modal 100% elements // Modal 100% elements
const completionModalTitleEl = document.getElementById('completion_modal_title'); const completionModalTitleEl = document.getElementById('completion_modal_title');
const completionModalBodyTextEl = document.getElementById('completion_modal_body_text'); const completionModalBodyTextEl = document.getElementById('completion_modal_body_text');
@@ -97,6 +104,8 @@ window.onload = async function () {
let winnerUsersLoadedOnce = false; let winnerUsersLoadedOnce = false;
let winnersByYear = new Map(); // year -> [{ year, userId, name, points }] let winnersByYear = new Map(); // year -> [{ year, userId, name, points }]
let winnerUsers = []; // admin list: {id, name, email, ...} let winnerUsers = []; // admin list: {id, name, email, ...}
let oscarDatesLoadedOnce = false;
let oscarDatesByYear = {}; // { "2026": "2026-03-15" }
const DEFAULT_COMPLETION_MODAL = Object.freeze({ const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉', title: 'Félicitations! very nice 🎉🎉🎉',
@@ -474,6 +483,81 @@ window.onload = async function () {
return n; return n;
} }
function isIsoDateString(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim());
}
async function fetchOscarDates() {
const res = await fetch('/api/settings/oscar-dates', { method: 'GET' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data?.dates && typeof data.dates === 'object' ? data.dates : {};
}
async function ensureOscarDatesLoaded(options = {}) {
const force = !!options.force;
if (oscarDatesLoadedOnce && !force) return;
try {
oscarDatesByYear = await fetchOscarDates();
oscarDatesLoadedOnce = true;
} catch (_) {
oscarDatesByYear = {};
oscarDatesLoadedOnce = true;
}
}
async function setOscarDateForYear(year, dateStrOrEmpty) {
const res = await fetch(`/api/settings/oscar-date/${encodeURIComponent(String(year))}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ date: dateStrOrEmpty }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function renderOscarDatesCurrent(cleanedYears) {
if (!oscarDateCurrentEl) return;
const years = Array.isArray(cleanedYears) ? cleanedYears : [];
if (!years.length) {
oscarDateCurrentEl.textContent = '—';
return;
}
const lines = years.map((y) => {
const key = String(y);
const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null;
const date = (typeof val === 'string' && isIsoDateString(val)) ? val : '— (défaut: 03-15)';
return `${key}: ${date}`;
});
oscarDateCurrentEl.textContent = lines.join('\n');
oscarDateCurrentEl.style.whiteSpace = 'pre';
}
function applyOscarDateSelection() {
if (!oscarDateYearSelect || !oscarDateValueInput) return;
const y = parseYear(oscarDateYearSelect.value);
if (!y) {
oscarDateValueInput.value = '';
return;
}
const key = String(y);
const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null;
oscarDateValueInput.value = (typeof val === 'string' && isIsoDateString(val)) ? val : '';
}
async function fetchWinners() { async function fetchWinners() {
const res = await fetch('/api/settings/winners', { method: 'GET' }); const res = await fetch('/api/settings/winners', { method: 'GET' });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -803,6 +887,84 @@ window.onload = async function () {
} }
} }
// Oscar date (settings tab) wiring
if (oscarDateYearSelect) {
oscarDateYearSelect.addEventListener('change', () => {
applyOscarDateSelection();
});
}
if (saveOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) {
saveOscarDateBtn.addEventListener('click', async () => {
const y = parseYear(oscarDateYearSelect.value);
if (!y) {
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
return;
}
const dateValue = String(oscarDateValueInput.value || '').trim();
if (dateValue && !isIsoDateString(dateValue)) {
showResponse('warning', 'Date invalide. Format attendu: YYYY-MM-DD');
return;
}
saveOscarDateBtn.disabled = true;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = true;
const oldText = saveOscarDateBtn.textContent;
saveOscarDateBtn.textContent = 'Enregistrement...';
try {
const result = await setOscarDateForYear(y, dateValue);
if (result?.date) {
oscarDatesByYear[String(y)] = String(result.date);
showResponse('success', `Date Oscars enregistrée pour ${y}: ${result.date}`);
} else {
delete oscarDatesByYear[String(y)];
showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`);
}
await refreshYears();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
saveOscarDateBtn.disabled = false;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = false;
saveOscarDateBtn.textContent = oldText;
applyOscarDateSelection();
}
});
}
if (clearOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) {
clearOscarDateBtn.addEventListener('click', async () => {
const y = parseYear(oscarDateYearSelect.value);
if (!y) {
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
return;
}
clearOscarDateBtn.disabled = true;
if (saveOscarDateBtn) saveOscarDateBtn.disabled = true;
const oldText = clearOscarDateBtn.textContent;
clearOscarDateBtn.textContent = 'Effacement...';
try {
oscarDateValueInput.value = '';
await setOscarDateForYear(y, '');
delete oscarDatesByYear[String(y)];
showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`);
await refreshYears();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
clearOscarDateBtn.disabled = false;
if (saveOscarDateBtn) saveOscarDateBtn.disabled = false;
clearOscarDateBtn.textContent = oldText;
applyOscarDateSelection();
}
});
}
if (deleteUserVerifyInputEl && confirmDeleteUserBtn) { if (deleteUserVerifyInputEl && confirmDeleteUserBtn) {
deleteUserVerifyInputEl.addEventListener('input', () => { deleteUserVerifyInputEl.addEventListener('input', () => {
const expected = String(currentDeleteTarget?.email || '').trim(); const expected = String(currentDeleteTarget?.email || '').trim();
@@ -1402,6 +1564,9 @@ window.onload = async function () {
.filter((y) => Number.isInteger(y) && y >= 1900 && y <= 3000) .filter((y) => Number.isInteger(y) && y >= 1900 && y <= 3000)
.sort((a, b) => b - a); .sort((a, b) => b - a);
// Ensure Oscar dates cache is loaded (for settings UI).
await ensureOscarDatesLoaded();
// Rebuild manage dropdown from scratch (so removed years disappear) // Rebuild manage dropdown from scratch (so removed years disappear)
const previousSelection = manageYearSelect.value; const previousSelection = manageYearSelect.value;
manageYearSelect.innerHTML = ''; manageYearSelect.innerHTML = '';
@@ -1471,6 +1636,48 @@ window.onload = async function () {
} }
} }
// Oscar date year dropdown (settings tab)
if (oscarDateYearSelect && oscarDateValueInput && saveOscarDateBtn && clearOscarDateBtn) {
const previousOscarYearSelection = String(oscarDateYearSelect.value || '');
oscarDateYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune année (ajoute un film)';
opt.disabled = true;
opt.selected = true;
oscarDateYearSelect.appendChild(opt);
oscarDateYearSelect.disabled = true;
oscarDateValueInput.disabled = true;
saveOscarDateBtn.disabled = true;
clearOscarDateBtn.disabled = true;
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
oscarDateYearSelect.appendChild(opt);
});
const desired =
(previousOscarYearSelection && cleanedYears.map(String).includes(previousOscarYearSelection))
? previousOscarYearSelection
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: String(cleanedYears[0]);
oscarDateYearSelect.disabled = false;
oscarDateValueInput.disabled = false;
saveOscarDateBtn.disabled = false;
clearOscarDateBtn.disabled = false;
oscarDateYearSelect.value = desired;
}
}
applyOscarDateSelection();
renderOscarDatesCurrent(cleanedYears);
// Winners year dropdown (optional tab) // Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears); setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache(); applyWinnerFormFromCache();
+32 -1
View File
@@ -11,6 +11,24 @@ window.onload = async function () {
} }
} }
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
// Use local noon to avoid DST/midnight edge cases for "days left" math.
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesLastUpdated(year, token) { async function fetchMoviesLastUpdated(year, token) {
const el = document.getElementById('movies-last-updated'); const el = document.getElementById('movies-last-updated');
if (!el) return; if (!el) return;
@@ -40,7 +58,20 @@ window.onload = async function () {
const oscarYearEl = document.getElementById('oscar-year'); const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear); if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
const targetDate = new Date(`March 15, ${activeYear}`); const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
new Date(`March 15, ${activeYear} 12:00:00`);
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
}
}
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
+1 -1
View File
@@ -106,7 +106,7 @@
<!-- This will display the ratio --> <!-- This will display the ratio -->
</div> </div>
<h3 class="fs-6">Temps avant les Oscars (15 mars <span id="oscar-year">—</span>) - <span id="time-left"></span></h3> <h3 class="fs-6">Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) - <span id="time-left"></span></h3>
<h3 class="fs-6 mb-0"> <h3 class="fs-6 mb-0">
</h3> </h3>
+136
View File
@@ -11,6 +11,7 @@ const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year"; const ACTIVE_YEAR_KEY = "active_oscar_year";
const COMPLETION_MODAL_KEY = "completion_modal_content"; const COMPLETION_MODAL_KEY = "completion_modal_content";
const WINNERS_BY_YEAR_KEY = "winners_by_year"; const WINNERS_BY_YEAR_KEY = "winners_by_year";
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const DEFAULT_COMPLETION_MODAL = Object.freeze({ const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉", title: "Félicitations! very nice 🎉🎉🎉",
@@ -37,6 +38,52 @@ function parseOptionalPoints(raw) {
return Math.round(n); return Math.round(n);
} }
function parseIsoDateString(raw) {
// Expected format: YYYY-MM-DD
if (raw === undefined || raw === null) return null;
const s = String(raw).trim();
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || year < 1900 || year > 3000) return null;
if (!Number.isInteger(month) || month < 1 || month > 12) return null;
if (!Number.isInteger(day) || day < 1 || day > 31) return null;
// Validate calendar date (handles month/day overflow like Feb 30).
const d = new Date(Date.UTC(year, month - 1, day));
if (
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day
) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function normalizeOscarDatesByYear(rawValue) {
// Stored format: { "2026": "2026-03-15" }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {};
for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k);
if (!year) continue;
const date = parseIsoDateString(v);
if (!date) continue;
out[String(year)] = date;
}
return out;
}
function normalizeWinnersByYear(rawValue) { function normalizeWinnersByYear(rawValue) {
// Stored format: // Stored format:
// - New: { "2026": [ { userId, points? }, { userId, points? } ] } // - New: { "2026": [ { userId, points? }, { userId, points? } ] }
@@ -127,6 +174,95 @@ router.get("/year", async (req, res) => {
} }
}); });
// Public: get Oscar date for a given year (with fallback).
// - If no custom date is configured, fallback is March 15 of that year (legacy behavior).
router.get("/oscar-date", async (req, res) => {
try {
const year = parseOscarYear(req.query?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
const configured = dates[String(year)] || null;
const effectiveDate = configured || `${String(year)}-03-15`;
return res.status(200).json({
year,
date: configured, // configured value (null if unset)
effectiveDate, // always present
});
} catch (err) {
// Even if DB is down, keep the legacy fallback.
const year = parseOscarYear(req.query?.year);
if (year) {
return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get all configured Oscar dates.
router.get("/oscar-dates", async (req, res) => {
try {
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
return res.status(200).json({ dates });
} catch (err) {
return res.status(200).json({ dates: {} });
}
});
// Admin-only: set/clear Oscar date for a given year.
// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear.
router.put("/oscar-date/:year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.params?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const rawDate = req.body?.date;
const date = parseIsoDateString(rawDate);
const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === "");
if (!wantsClear && !date) {
return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
if (wantsClear) {
delete dates[String(year)];
} else {
dates[String(year)] = date;
}
await Setting.findOneAndUpdate(
{ key: OSCAR_DATES_BY_YEAR_KEY },
{ $set: { value: dates } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, date: dates[String(year)] || null });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: set current active Oscar year. // Admin-only: set current active Oscar year.
router.put("/year", async (req, res) => { router.put("/year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1]; const token = req.headers.authorization?.split(" ")[1];