add video backend server, fix styling and more #54
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user