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
4 changed files with 253 additions and 70 deletions
Showing only changes of commit 52eaee65f6 - Show all commits
+7 -6
View File
@@ -397,9 +397,10 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-xxl-8"> <div class="col-12 col-xxl-8">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Définir le gagnant par année</h4> <h4 class="mb-1">Définir les gagnants par année (égalité)</h4>
<div class="text-muted mb-3"> <div class="text-muted mb-3">
Choisis une année, un utilisateur, et des points (optionnel). Laisse le champ points vide si tu ne veux pas l’afficher. Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points).
Choisis un utilisateur + des points (optionnel), puis clique <strong>Ajouter</strong>.
</div> </div>
<div class="row g-3"> <div class="row g-3">
@@ -413,7 +414,7 @@
<select id="winner_user" class="form-select"> <select id="winner_user" class="form-select">
<option value="">—</option> <option value="">—</option>
</select> </select>
<div class="form-text">Si tu choisis “—”, le gagnant est supprimé pour l’année.</div> <div class="form-text">Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label for="winner_points" class="form-label">Points (optionnel)</label> <label for="winner_points" class="form-label">Points (optionnel)</label>
@@ -423,12 +424,12 @@
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3"> <div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button> <button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button>
<button type="button" id="winner_clear" class="btn btn-outline-danger">Supprimer</button> <button type="button" id="winner_clear" class="btn btn-outline-danger">Tout supprimer</button>
<button type="button" id="winner_save" class="btn btn-warning">Enregistrer</button> <button type="button" id="winner_save" class="btn btn-warning">Ajouter</button>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<div class="text-muted small mb-1">Gagnant actuel</div> <div class="text-muted small mb-1">Gagnants actuels</div>
<div id="winner_current" class="border rounded p-2 bg-light">—</div> <div id="winner_current" class="border rounded p-2 bg-light">—</div>
</div> </div>
</div> </div>
+101 -28
View File
@@ -95,7 +95,7 @@ window.onload = async function () {
let activeYear = null; let activeYear = null;
let winnersLoadedOnce = false; let winnersLoadedOnce = false;
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, ...}
const DEFAULT_COMPLETION_MODAL = Object.freeze({ const DEFAULT_COMPLETION_MODAL = Object.freeze({
@@ -484,7 +484,7 @@ window.onload = async function () {
return Array.isArray(data?.winners) ? data.winners : []; return Array.isArray(data?.winners) ? data.winners : [];
} }
async function saveWinner(year, userId, points) { async function addWinner(year, userId, points) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, { const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -504,6 +504,19 @@ window.onload = async function () {
return data; return data;
} }
async function removeWinner(year, userId) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}/${encodeURIComponent(String(userId))}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
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 buildWinnerUsersOptions(users) { function buildWinnerUsersOptions(users) {
if (!winnerUserSelect) return; if (!winnerUserSelect) return;
const list = Array.isArray(users) ? users : []; const list = Array.isArray(users) ? users : [];
@@ -579,27 +592,81 @@ window.onload = async function () {
winnerCurrentEl.textContent = '—'; winnerCurrentEl.textContent = '—';
return; return;
} }
const w = getWinnerForYear(y); const list = getWinnerForYear(y);
if (!w) { const winners = Array.isArray(list) ? list : [];
if (!winners.length) {
winnerCurrentEl.textContent = `${y}: (aucun gagnant)`; winnerCurrentEl.textContent = `${y}: (aucun gagnant)`;
return; return;
} }
const points = w.points === null || w.points === undefined ? null : Number(w.points);
const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`; // Sort for stable display: points desc (null last), then name
winnerCurrentEl.textContent = `${y}: ${w.name || '(utilisateur supprimé)'}${pointsLabel}`; const sorted = winners.slice().sort((a, b) => {
const ap = a?.points === null || a?.points === undefined ? null : Number(a.points);
const bp = b?.points === null || b?.points === undefined ? null : Number(b.points);
if (ap === null && bp !== null) return 1;
if (ap !== null && bp === null) return -1;
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' });
});
const rows = sorted.map((w) => {
const name = w?.name || '(utilisateur supprimé)';
const pts = w?.points === null || w?.points === undefined ? null : Number(w.points);
const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` <span class="text-muted">— ${pts} pts</span>`;
const uid = String(w?.userId || '');
return `
<div class="d-flex justify-content-between align-items-center border rounded px-2 py-1 mb-2">
<div class="me-2"><strong>${name}</strong>${ptsLabel}</div>
<button type="button" class="btn btn-sm btn-outline-danger" data-winner-remove-year="${y}" data-winner-remove-user="${uid}">Retirer</button>
</div>
`;
}).join('');
winnerCurrentEl.innerHTML = `
<div class="fw-semibold mb-2">${y}</div>
${rows}
`;
winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => {
btn.addEventListener('click', async () => {
const yearRaw = btn.getAttribute('data-winner-remove-year');
const userRaw = btn.getAttribute('data-winner-remove-user');
const yy = parseYear(yearRaw);
const uid = String(userRaw || '').trim();
if (!yy || !uid) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
await removeWinner(yy, uid);
await loadWinners({ force: true });
showResponse('success', `Gagnant retiré pour ${yy}.`);
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
} }
function applyWinnerFormFromCache() { function applyWinnerFormFromCache() {
if (!winnerYearSelect) return; if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value); const y = parseYear(winnerYearSelect.value);
if (!y) return; if (!y) return;
const w = getWinnerForYear(y);
if (winnerUserSelect) { if (winnerUserSelect) {
winnerUserSelect.value = w?.userId ? String(w.userId) : ''; winnerUserSelect.value = '';
} }
if (winnerPointsEl) { if (winnerPointsEl) {
const pts = w?.points; // Convenience: if all winners share the same non-null points, pre-fill it.
winnerPointsEl.value = pts === null || pts === undefined ? '' : String(pts); const list = getWinnerForYear(y);
const winners = Array.isArray(list) ? list : [];
const ptsVals = winners
.map((w) => (w?.points === null || w?.points === undefined) ? null : Number(w.points))
.filter((n) => Number.isFinite(n));
const unique = Array.from(new Set(ptsVals));
winnerPointsEl.value = unique.length === 1 ? String(unique[0]) : '';
} }
renderWinnerCurrent(); renderWinnerCurrent();
} }
@@ -609,19 +676,21 @@ window.onload = async function () {
if (winnersLoadedOnce && !force) return; if (winnersLoadedOnce && !force) return;
const list = await fetchWinners(); const list = await fetchWinners();
winnersLoadedOnce = true; winnersLoadedOnce = true;
winnersByYear = new Map( const grouped = new Map();
list (Array.isArray(list) ? list : [])
.filter((w) => parseYear(w?.year)) .filter((w) => parseYear(w?.year))
.map((w) => [ .forEach((w) => {
String(w.year), const key = String(w.year);
{ const arr = grouped.get(key) || [];
year: Number(w.year), arr.push({
userId: String(w.userId || ''), year: Number(w.year),
name: w.name || null, userId: String(w.userId || ''),
points: w.points ?? null, name: w.name || null,
} points: w.points ?? null,
]) });
); grouped.set(key, arr);
});
winnersByYear = grouped;
applyWinnerFormFromCache(); applyWinnerFormFromCache();
} }
@@ -1219,12 +1288,12 @@ window.onload = async function () {
if (!winnerYearSelect) return; if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value); const y = parseYear(winnerYearSelect.value);
if (!y) return; if (!y) return;
const ok = window.confirm(`Supprimer le gagnant pour ${y} ?`); const ok = window.confirm(`Supprimer tous les gagnants pour ${y} ?`);
if (!ok) return; if (!ok) return;
try { try {
if (winnerUserSelect) winnerUserSelect.value = ''; if (winnerUserSelect) winnerUserSelect.value = '';
if (winnerPointsEl) winnerPointsEl.value = ''; if (winnerPointsEl) winnerPointsEl.value = '';
await saveWinner(y, '', null); await addWinner(y, '', null);
await loadWinners({ force: true }); await loadWinners({ force: true });
showResponse('success', `Gagnant supprimé pour ${y}.`); showResponse('success', `Gagnant supprimé pour ${y}.`);
} catch (err) { } catch (err) {
@@ -1242,6 +1311,10 @@ window.onload = async function () {
return; return;
} }
const userId = String(winnerUserSelect?.value || '').trim(); const userId = String(winnerUserSelect?.value || '').trim();
if (!userId) {
showResponse('warning', 'Choisis un utilisateur.');
return;
}
const pointsRaw = String(winnerPointsEl?.value || '').trim(); const pointsRaw = String(winnerPointsEl?.value || '').trim();
const points = pointsRaw === '' ? null : Number(pointsRaw); const points = pointsRaw === '' ? null : Number(pointsRaw);
const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points); const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points);
@@ -1250,9 +1323,9 @@ window.onload = async function () {
const old = winnerSaveBtn.textContent; const old = winnerSaveBtn.textContent;
winnerSaveBtn.textContent = 'Enregistrement...'; winnerSaveBtn.textContent = 'Enregistrement...';
try { try {
await saveWinner(y, userId, pointsToSend); await addWinner(y, userId, pointsToSend);
await loadWinners({ force: true }); await loadWinners({ force: true });
showResponse('success', `Gagnant enregistré pour ${y}.`); showResponse('success', `Gagnant ajouté pour ${y}.`);
} catch (err) { } catch (err) {
showResponse('danger', err.message || 'Erreur réseau'); showResponse('danger', err.message || 'Erreur réseau');
} finally { } finally {
+41 -9
View File
@@ -49,19 +49,51 @@ window.onload = async function () {
return; return;
} }
// Ensure sorted by year desc // Group by year (supports ties)
list.sort((a, b) => Number(b?.year || 0) - Number(a?.year || 0)); const byYear = new Map();
list.forEach((w) => {
const y = Number(w?.year || 0);
if (!Number.isInteger(y) || y < 1900 || y > 3000) return;
const arr = byYear.get(String(y)) || [];
arr.push({
year: y,
name: w?.name || '(utilisateur supprimé)',
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
});
byYear.set(String(y), arr);
});
const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a);
winnersList.innerHTML = ''; winnersList.innerHTML = '';
list.forEach((w) => { years.forEach((year) => {
const year = Number(w?.year); const winnersForYear = byYear.get(String(year)) || [];
const name = w?.name || '(utilisateur supprimé)'; const sorted = winnersForYear.slice().sort((a, b) => {
const points = w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points); const ap = a.points === null || Number.isNaN(a.points) ? null : a.points;
const pointsLabel = points === null || Number.isNaN(points) ? '' : ` — ${points} pts`; const bp = b.points === null || Number.isNaN(b.points) ? null : b.points;
if (ap === null && bp !== null) return 1;
if (ap !== null && bp === null) return -1;
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
});
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'list-group-item d-flex justify-content-between align-items-center'; item.className = 'list-group-item';
const winnersHtml = sorted
.map((w) => {
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
const ptsLabel = pts === null ? '' : ` <span class="text-muted">— ${pts} pts</span>`;
return `<div><strong>${w.name}</strong>${ptsLabel}</div>`;
})
.join('');
item.innerHTML = ` item.innerHTML = `
<div><strong>${year || '—'}</strong> : ${name}${pointsLabel}</div> <div class="d-flex justify-content-between align-items-center">
<div class="fw-semibold">${year}</div>
<span class="badge bg-secondary">${sorted.length}</span>
</div>
<div class="mt-2">${winnersHtml}</div>
`; `;
winnersList.appendChild(item); winnersList.appendChild(item);
}); });
+104 -27
View File
@@ -38,17 +38,33 @@ function parseOptionalPoints(raw) {
} }
function normalizeWinnersByYear(rawValue) { function normalizeWinnersByYear(rawValue) {
// Stored format:
// - New: { "2026": [ { userId, points? }, { userId, points? } ] }
// - Legacy: { "2026": { userId, points? } }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {}; const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {}; const out = {};
for (const [k, v] of Object.entries(value)) { for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k); const year = parseOscarYear(k);
if (!year) continue; if (!year) continue;
const entry = v && typeof v === "object" && !Array.isArray(v) ? v : {};
const userId = typeof entry.userId === "string" ? entry.userId.trim() : ""; const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : [];
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue; const dedup = new Map(); // userId -> {userId, points}
const points = parseOptionalPoints(entry.points);
out[String(year)] = { userId, points }; for (const rawEntry of list) {
const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {};
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
const points = parseOptionalPoints(entry.points);
dedup.set(String(userId), { userId: String(userId), points });
}
const normalizedList = Array.from(dedup.values());
if (normalizedList.length) {
out[String(year)] = normalizedList;
}
} }
return out; return out;
} }
@@ -188,11 +204,13 @@ router.get("/winners", async (req, res) => {
const normalized = normalizeWinnersByYear(existing?.value); const normalized = normalizeWinnersByYear(existing?.value);
const entries = Object.entries(normalized) const entries = Object.entries(normalized)
.map(([yearStr, v]) => ({ .flatMap(([yearStr, list]) =>
year: Number(yearStr), (Array.isArray(list) ? list : []).map((v) => ({
userId: v.userId, year: Number(yearStr),
points: v.points ?? null, userId: v.userId,
})) points: v.points ?? null,
}))
)
.filter((e) => parseOscarYear(e.year)) .filter((e) => parseOscarYear(e.year))
.sort((a, b) => b.year - a.year); .sort((a, b) => b.year - a.year);
@@ -215,8 +233,10 @@ router.get("/winners", async (req, res) => {
} }
}); });
// Admin-only: set/clear winner for a given year. Points optional. // Admin-only: add/upsert/clear winners for a given year. Points optional.
// Body: { userId: "<mongoId>" | "" | null, points?: number|null } // - Clear all for year: { userId: "" } (legacy behavior)
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
// - Replace full list for year: { winners: [{ userId, points? }, ...] }
router.put("/winners/:year", async (req, res) => { router.put("/winners/:year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1]; const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" }); if (!token) return res.status(401).json({ message: "Authentication token is required" });
@@ -232,14 +252,31 @@ router.put("/winners/:year", async (req, res) => {
return res.status(400).json({ message: "Année invalide (ex: 2026)" }); return res.status(400).json({ message: "Année invalide (ex: 2026)" });
} }
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
// Replace list mode: { winners: [...] }
if (Array.isArray(req.body?.winners)) {
const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || [];
if (!replacement.length) {
delete winners[String(year)];
} else {
winners[String(year)] = replacement;
}
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[String(year)] || [] });
}
// Add/upsert/clear mode (legacy-compatible): { userId, points? }
const rawUserId = req.body?.userId; const rawUserId = req.body?.userId;
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : ""; const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
const points = parseOptionalPoints(req.body?.points); const points = parseOptionalPoints(req.body?.points);
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value"); // Clear all for year
const winners = normalizeWinnersByYear(existing?.value);
// Clear winner for year
if (!userId) { if (!userId) {
delete winners[String(year)]; delete winners[String(year)];
await Setting.findOneAndUpdate( await Setting.findOneAndUpdate(
@@ -247,7 +284,7 @@ router.put("/winners/:year", async (req, res) => {
{ $set: { value: winners } }, { $set: { value: winners } },
{ upsert: true, new: true } { upsert: true, new: true }
); );
return res.status(200).json({ year, winner: null }); return res.status(200).json({ year, winners: [] });
} }
if (!mongoose.Types.ObjectId.isValid(userId)) { if (!mongoose.Types.ObjectId.isValid(userId)) {
@@ -255,11 +292,13 @@ router.put("/winners/:year", async (req, res) => {
} }
const winnerUser = await User.findById(userId).select("name").lean(); const winnerUser = await User.findById(userId).select("name").lean();
if (!winnerUser) { if (!winnerUser) return res.status(404).json({ message: "User not found" });
return res.status(404).json({ message: "User not found" });
}
winners[String(year)] = { userId: String(userId), points }; const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
next.push({ userId: String(userId), points });
winners[key] = next;
await Setting.findOneAndUpdate( await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY }, { key: WINNERS_BY_YEAR_KEY },
@@ -269,12 +308,8 @@ router.put("/winners/:year", async (req, res) => {
return res.status(200).json({ return res.status(200).json({
year, year,
winner: { winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null },
year, winners: winners[String(year)] || [],
userId: String(userId),
name: winnerUser.name || null,
points: points ?? null,
},
}); });
} catch (err) { } catch (err) {
if (err instanceof jwt.JsonWebTokenError) { if (err instanceof jwt.JsonWebTokenError) {
@@ -284,5 +319,47 @@ router.put("/winners/:year", async (req, res) => {
} }
}); });
// Admin-only: remove a specific winner for a given year.
router.delete("/winners/:year/:userId", 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 userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : "";
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(400).json({ message: "userId invalide" });
}
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
if (next.length) winners[key] = next;
else delete winners[key];
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[key] || [] });
} 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 });
}
});
module.exports = router; module.exports = router;