add video backend server, fix styling and more #54
+7
-6
@@ -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>
|
||||||
|
|||||||
+102
-28
@@ -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,82 @@ 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('');
|
||||||
|
|
||||||
|
const tieLabel = sorted.length > 1 ? ' <span class="text-muted fw-normal">(égalité)</span>' : '';
|
||||||
|
winnerCurrentEl.innerHTML = `
|
||||||
|
<div class="fw-semibold mb-2">${y}${tieLabel}</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 +677,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 +1289,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 +1312,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 +1324,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 {
|
||||||
|
|||||||
+68
-14
@@ -1,5 +1,7 @@
|
|||||||
window.onload = async function () {
|
window.onload = async function () {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
|
let winnersCache = null;
|
||||||
|
let completionsCache = null;
|
||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
@@ -47,19 +49,52 @@ 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 tieLabel = sorted.length > 1 ? ' <span class="text-muted">(égalité)</span>' : '';
|
||||||
|
|
||||||
|
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}${tieLabel}</div>
|
||||||
|
<span class="badge bg-secondary">${sorted.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">${winnersHtml}</div>
|
||||||
`;
|
`;
|
||||||
winnersList.appendChild(item);
|
winnersList.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -172,6 +207,11 @@ window.onload = async function () {
|
|||||||
fetchCompletions(),
|
fetchCompletions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
winnersCache = winners;
|
||||||
|
completionsCache = completions;
|
||||||
|
renderWinners(winnersCache);
|
||||||
|
renderCompletions(completionsCache);
|
||||||
|
|
||||||
if (!statsRes.ok) {
|
if (!statsRes.ok) {
|
||||||
throw new Error('Failed to fetch user stats');
|
throw new Error('Failed to fetch user stats');
|
||||||
}
|
}
|
||||||
@@ -180,9 +220,7 @@ window.onload = async function () {
|
|||||||
const userTableBody = document.getElementById('user-table-body');
|
const userTableBody = document.getElementById('user-table-body');
|
||||||
const table = document.querySelector('.user-table');
|
const table = document.querySelector('.user-table');
|
||||||
const spinner = document.getElementById('loading-spinner');
|
const spinner = document.getElementById('loading-spinner');
|
||||||
|
const statsError = document.getElementById('stats-error');
|
||||||
renderWinners(winners);
|
|
||||||
renderCompletions(completions);
|
|
||||||
|
|
||||||
// Ensure watchedRatio is treated as a number and sort in descending order
|
// Ensure watchedRatio is treated as a number and sort in descending order
|
||||||
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
||||||
@@ -205,6 +243,7 @@ window.onload = async function () {
|
|||||||
// Hide spinner and show table after loading
|
// Hide spinner and show table after loading
|
||||||
spinner.style.display = 'none';
|
spinner.style.display = 'none';
|
||||||
table.style.display = 'table';
|
table.style.display = 'table';
|
||||||
|
if (statsError) statsError.classList.add('d-none');
|
||||||
|
|
||||||
// Add event listener for user links
|
// Add event listener for user links
|
||||||
document.querySelectorAll('.user-link').forEach(link => {
|
document.querySelectorAll('.user-link').forEach(link => {
|
||||||
@@ -233,9 +272,24 @@ window.onload = async function () {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user statistics:', error);
|
console.error('Error loading user statistics:', error);
|
||||||
|
const spinner = document.getElementById('loading-spinner');
|
||||||
|
const table = document.querySelector('.user-table');
|
||||||
|
const statsError = document.getElementById('stats-error');
|
||||||
|
if (spinner) spinner.style.display = 'none';
|
||||||
|
if (table) table.style.display = 'none';
|
||||||
|
if (statsError) {
|
||||||
|
statsError.textContent = "Impossible de charger le classement pour le moment.";
|
||||||
|
statsError.classList.remove('d-none');
|
||||||
|
}
|
||||||
// Still attempt to render these sections even if stats fail (best effort)
|
// Still attempt to render these sections even if stats fail (best effort)
|
||||||
try { renderWinners(await fetchWinners()); } catch (_) {}
|
try {
|
||||||
try { renderCompletions(await fetchCompletions()); } catch (_) {}
|
if (winnersCache === null) winnersCache = await fetchWinners();
|
||||||
|
renderWinners(winnersCache);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if (completionsCache === null) completionsCache = await fetchCompletions();
|
||||||
|
renderCompletions(completionsCache);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log-off functionality
|
// Log-off functionality
|
||||||
|
|||||||
+69
-35
@@ -30,6 +30,14 @@
|
|||||||
.navbar-toggler-icon {
|
.navbar-toggler-icon {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats tabs */
|
||||||
|
.stats-tabs .nav-link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stats-tab-panel {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
@@ -61,43 +69,69 @@
|
|||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h2>Statistiques des utilisateurs</h2>
|
<h2>Statistiques des utilisateurs</h2>
|
||||||
|
|
||||||
<!-- Winners section -->
|
<!-- Tabs -->
|
||||||
<div class="mt-4">
|
<ul class="nav nav-tabs stats-tabs mt-3" id="statsTabs" role="tablist">
|
||||||
<h4 class="mb-2">Gagnants</h4>
|
<li class="nav-item" role="presentation">
|
||||||
<div class="text-muted mb-2">Définis dans l’admin (optionnel).</div>
|
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
|
||||||
<div id="winners-list" class="list-group mb-4">
|
Classement
|
||||||
<div class="list-group-item text-muted">Chargement…</div>
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
|
||||||
|
Gagnants
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false">
|
||||||
|
100%
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content stats-tab-panel" id="statsTabsContent">
|
||||||
|
<!-- Ranking tab -->
|
||||||
|
<div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0">
|
||||||
|
<div id="stats-error" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
|
||||||
|
<!-- Spinner shown while loading -->
|
||||||
|
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table for users and movies watched -->
|
||||||
|
<table class="table user-table" style="display: none;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nom</th>
|
||||||
|
<th scope="col">Total des films regardés (pts)</th>
|
||||||
|
<th scope="col">Ratio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="user-table-body">
|
||||||
|
<!-- User rows will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Winners tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
|
||||||
|
<h4 class="mb-2">Gagnants</h4>
|
||||||
|
<div class="text-muted mb-2">Définis dans l’admin (optionnel).</div>
|
||||||
|
<div id="winners-list" class="list-group mb-4">
|
||||||
|
<div class="list-group-item text-muted">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 100% completers tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
|
||||||
|
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
|
||||||
|
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
|
||||||
|
<div id="completers-by-year" class="mb-4">
|
||||||
|
<div class="text-muted">Chargement…</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 100% completers section -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
|
|
||||||
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
|
|
||||||
<div id="completers-by-year" class="mb-4">
|
|
||||||
<div class="text-muted">Chargement…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Spinner shown while loading -->
|
|
||||||
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table for users and movies watched -->
|
|
||||||
<table class="table user-table" style="display: none;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Nom</th>
|
|
||||||
<th scope="col">Total des films regardés (pts)</th>
|
|
||||||
<th scope="col">Ratio</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="user-table-body">
|
|
||||||
<!-- User rows will be inserted here -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal for displaying movies watched -->
|
<!-- Modal for displaying movies watched -->
|
||||||
|
|||||||
+104
-27
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user