WIP: Add points system for movies and picks, admin configuration, and stats page updates
This commit is contained in:
@@ -72,6 +72,7 @@
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||
</div>
|
||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
|
||||
</div>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
@@ -87,6 +88,11 @@
|
||||
Gagnants
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-categories-tab" data-bs-toggle="tab" data-bs-target="#admin-categories" type="button" role="tab" aria-controls="admin-categories" aria-selected="false">
|
||||
Catégories Oscars
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
|
||||
Backup / Restore
|
||||
@@ -125,6 +131,11 @@
|
||||
Date Oscars
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-settings-subtab-points" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-points" type="button" role="tab" aria-controls="admin-settings-pane-points" aria-selected="false">
|
||||
Points
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content pt-3">
|
||||
@@ -194,6 +205,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points configuration -->
|
||||
<div class="tab-pane fade" id="admin-settings-pane-points" role="tabpanel" aria-labelledby="admin-settings-subtab-points" tabindex="0">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="text-muted small mb-3">
|
||||
Configure les points attribués pour les films regardés et les bonnes réponses aux Oscars.
|
||||
</div>
|
||||
|
||||
<div id="points-config-alert" class="alert d-none" role="alert"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="points-per-movie" class="form-label">Points par film regardé</label>
|
||||
<input type="number" class="form-control" id="points-per-movie" min="0" step="1" value="1">
|
||||
<div class="form-text">Points attribués pour chaque film complété.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="points-per-correct-pick" class="form-label">Points par bonne réponse</label>
|
||||
<input type="number" class="form-control" id="points-per-correct-pick" min="0" step="1" value="1">
|
||||
<div class="form-text">Points attribués pour chaque bonne réponse aux Oscars.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="points-config-reload" class="btn btn-outline-secondary">Recharger</button>
|
||||
<button type="button" id="points-config-save" class="btn btn-warning">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small mb-1">Calcul des points</div>
|
||||
<div class="border rounded p-2 bg-white">
|
||||
<div class="small">
|
||||
<strong>Total =</strong> (Films regardés × <span id="points-per-movie-preview">1</span>) + (Bonnes réponses × <span id="points-per-correct-pick-preview">1</span>)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -805,6 +856,158 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="admin-categories" role="tabpanel" aria-labelledby="admin-categories-tab">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
||||
<div>
|
||||
<h4 class="mb-1">Catégories Oscars</h4>
|
||||
<div class="text-muted">Importez les catégories depuis un PDF ou créez-les manuellement.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCategoriesSubTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="admin-categories-subtab-import" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-import" type="button" role="tab" aria-controls="admin-categories-pane-import" aria-selected="true">
|
||||
Import PDF
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-categories-subtab-manual" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-manual" type="button" role="tab" aria-controls="admin-categories-pane-manual" aria-selected="false">
|
||||
Création manuelle
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="admin-categories-subtab-list" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-list" type="button" role="tab" aria-controls="admin-categories-pane-list" aria-selected="false">
|
||||
Liste
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content pt-3">
|
||||
<!-- Import PDF -->
|
||||
<div class="tab-pane fade show active" id="admin-categories-pane-import" role="tabpanel" aria-labelledby="admin-categories-subtab-import" tabindex="0">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xxl-7">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="fw-semibold mb-1">Importer depuis un PDF</div>
|
||||
<div class="text-muted small mb-3">
|
||||
Téléchargez le fichier PDF de la feuille de props Oscars. Le système extraira automatiquement les catégories et les nommés.
|
||||
</div>
|
||||
|
||||
<div id="categories-import-alert" class="alert d-none" role="alert"></div>
|
||||
|
||||
<form id="import-pdf-form">
|
||||
<div class="mb-3">
|
||||
<label for="categories-year" class="form-label">Année</label>
|
||||
<select class="form-select" id="categories-year" required>
|
||||
<option value="">Chargement...</option>
|
||||
</select>
|
||||
<div class="form-text">Année des Oscars pour ces catégories.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="pdf-file" class="form-label">Fichier PDF</label>
|
||||
<input type="file" class="form-control" id="pdf-file" accept=".pdf,application/pdf" required>
|
||||
<div class="form-text">Format attendu: feuille de props Oscars avec catégories numérotées.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-warning">Importer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-5">
|
||||
<div class="border rounded p-3 bg-white">
|
||||
<div class="fw-semibold mb-2">Astuce</div>
|
||||
<div class="text-muted">
|
||||
Le PDF doit contenir des catégories numérotées (1., 2., etc.) suivies de leurs nommés.
|
||||
Vérifiez la liste après import pour corriger si nécessaire.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual creation -->
|
||||
<div class="tab-pane fade" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xxl-7">
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="fw-semibold mb-1">Créer une catégorie manuellement</div>
|
||||
<div class="text-muted small mb-3">
|
||||
Ajoutez une catégorie et ses nommés un par un.
|
||||
</div>
|
||||
|
||||
<div id="categories-manual-alert" class="alert d-none" role="alert"></div>
|
||||
|
||||
<form id="create-category-form">
|
||||
<div class="mb-3">
|
||||
<label for="manual-year" class="form-label">Année</label>
|
||||
<select class="form-select" id="manual-year" required>
|
||||
<option value="">Chargement...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="category-number" class="form-label">Numéro de catégorie</label>
|
||||
<input type="number" class="form-control" id="category-number" placeholder="1" min="1" max="50" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="category-name" class="form-label">Nom de la catégorie</label>
|
||||
<input type="text" class="form-control" id="category-name" placeholder="Best Picture" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="nominees-list" class="form-label">Nommés (un par ligne)</label>
|
||||
<textarea class="form-control" id="nominees-list" rows="10" placeholder="Anora The Brutalist Dune: Part Two" required></textarea>
|
||||
<div class="form-text">Entrez un nommé par ligne.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-primary">Créer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-5">
|
||||
<div class="border rounded p-3 bg-white">
|
||||
<div class="fw-semibold mb-2">Note</div>
|
||||
<div class="text-muted">
|
||||
Vous pouvez créer plusieurs catégories pour la même année.
|
||||
Le numéro de catégorie doit être unique par année.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="tab-pane fade" id="admin-categories-pane-list" role="tabpanel" aria-labelledby="admin-categories-subtab-list" tabindex="0">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Catégories importées</div>
|
||||
<div class="text-muted small">Gérez les catégories existantes</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select" id="list-year-filter" style="width: 150px;">
|
||||
<option value="">Toutes les années</option>
|
||||
</select>
|
||||
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categories-list-container" class="border rounded bg-white p-3">
|
||||
<div class="text-muted">Charge la liste…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
|
||||
<div class="card p-3">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
||||
@@ -1032,6 +1235,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit category modal -->
|
||||
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Modifier la catégorie</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit_category_id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_category_number" class="form-label">Numéro de catégorie</label>
|
||||
<input type="number" class="form-control" id="edit_category_number" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_category_name" class="form-label">Nom de la catégorie</label>
|
||||
<input type="text" class="form-control" id="edit_category_name" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit_nominees_list" class="form-label">Nommés (un par ligne)</label>
|
||||
<textarea class="form-control" id="edit_nominees_list" rows="10" required></textarea>
|
||||
<div class="form-text">Entrez un nommé par ligne.</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-category-alert" class="alert d-none" role="alert"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-primary" id="save-category-changes">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit movie modal -->
|
||||
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
|
||||
@@ -2094,6 +2094,97 @@ window.onload = async function () {
|
||||
// Winners year dropdown (optional tab)
|
||||
setWinnerYearOptions(cleanedYears);
|
||||
applyWinnerFormFromCache();
|
||||
|
||||
// Categories year dropdowns
|
||||
const categoriesYearSelect = document.getElementById('categories-year');
|
||||
const manualYearSelect = document.getElementById('manual-year');
|
||||
const listYearFilterSelect = document.getElementById('list-year-filter');
|
||||
|
||||
// Populate PDF import year dropdown
|
||||
if (categoriesYearSelect) {
|
||||
const previousCategoriesYear = categoriesYearSelect.value;
|
||||
categoriesYearSelect.innerHTML = '';
|
||||
|
||||
if (cleanedYears.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'Aucune année (ajoute un film)';
|
||||
opt.disabled = true;
|
||||
categoriesYearSelect.appendChild(opt);
|
||||
} else {
|
||||
cleanedYears.forEach((y) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(y);
|
||||
opt.textContent = String(y);
|
||||
categoriesYearSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const desired = previousCategoriesYear && cleanedYears.map(String).includes(previousCategoriesYear)
|
||||
? previousCategoriesYear
|
||||
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
|
||||
? String(activeYear)
|
||||
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
|
||||
if (desired) categoriesYearSelect.value = desired;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate manual creation year dropdown
|
||||
if (manualYearSelect) {
|
||||
const previousManualYear = manualYearSelect.value;
|
||||
manualYearSelect.innerHTML = '';
|
||||
|
||||
if (cleanedYears.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'Aucune année (ajoute un film)';
|
||||
opt.disabled = true;
|
||||
manualYearSelect.appendChild(opt);
|
||||
} else {
|
||||
cleanedYears.forEach((y) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(y);
|
||||
opt.textContent = String(y);
|
||||
manualYearSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const desired = previousManualYear && cleanedYears.map(String).includes(previousManualYear)
|
||||
? previousManualYear
|
||||
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
|
||||
? String(activeYear)
|
||||
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
|
||||
if (desired) manualYearSelect.value = desired;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate list filter year dropdown
|
||||
if (listYearFilterSelect) {
|
||||
const previousListYear = listYearFilterSelect.value;
|
||||
listYearFilterSelect.innerHTML = '';
|
||||
|
||||
// Add "All years" option
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = 'Toutes les années';
|
||||
listYearFilterSelect.appendChild(allOpt);
|
||||
|
||||
if (cleanedYears.length > 0) {
|
||||
cleanedYears.forEach((y) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(y);
|
||||
opt.textContent = String(y);
|
||||
listYearFilterSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
// Keep previous selection if still valid
|
||||
if (previousListYear && cleanedYears.map(String).includes(previousListYear)) {
|
||||
listYearFilterSelect.value = previousListYear;
|
||||
} else if (activeYear && cleanedYears.map(String).includes(String(activeYear))) {
|
||||
listYearFilterSelect.value = String(activeYear);
|
||||
} else {
|
||||
listYearFilterSelect.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
@@ -2485,5 +2576,424 @@ window.onload = async function () {
|
||||
|
||||
await refreshYears();
|
||||
await loadMoviesForManagement();
|
||||
|
||||
// Points configuration
|
||||
const pointsPerMovieInput = document.getElementById('points-per-movie');
|
||||
const pointsPerCorrectPickInput = document.getElementById('points-per-correct-pick');
|
||||
const pointsConfigReloadBtn = document.getElementById('points-config-reload');
|
||||
const pointsConfigSaveBtn = document.getElementById('points-config-save');
|
||||
const pointsConfigAlert = document.getElementById('points-config-alert');
|
||||
const pointsPerMoviePreview = document.getElementById('points-per-movie-preview');
|
||||
const pointsPerCorrectPickPreview = document.getElementById('points-per-correct-pick-preview');
|
||||
|
||||
async function loadPointsConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/points-config', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('Failed to load points config');
|
||||
const config = await res.json();
|
||||
|
||||
if (pointsPerMovieInput) pointsPerMovieInput.value = config.pointsPerMovie || 1;
|
||||
if (pointsPerCorrectPickInput) pointsPerCorrectPickInput.value = config.pointsPerCorrectPick || 1;
|
||||
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = config.pointsPerMovie || 1;
|
||||
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = config.pointsPerCorrectPick || 1;
|
||||
} catch (err) {
|
||||
console.error('Error loading points config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePointsPreview() {
|
||||
const moviePoints = pointsPerMovieInput ? parseInt(pointsPerMovieInput.value) || 1 : 1;
|
||||
const pickPoints = pointsPerCorrectPickInput ? parseInt(pointsPerCorrectPickInput.value) || 1 : 1;
|
||||
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = moviePoints;
|
||||
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = pickPoints;
|
||||
}
|
||||
|
||||
if (pointsPerMovieInput) {
|
||||
pointsPerMovieInput.addEventListener('input', updatePointsPreview);
|
||||
}
|
||||
if (pointsPerCorrectPickInput) {
|
||||
pointsPerCorrectPickInput.addEventListener('input', updatePointsPreview);
|
||||
}
|
||||
|
||||
if (pointsConfigReloadBtn) {
|
||||
pointsConfigReloadBtn.addEventListener('click', async () => {
|
||||
await loadPointsConfig();
|
||||
showResponse('success', 'Configuration rechargée');
|
||||
});
|
||||
}
|
||||
|
||||
if (pointsConfigSaveBtn) {
|
||||
pointsConfigSaveBtn.addEventListener('click', async () => {
|
||||
const pointsPerMovie = parseInt(pointsPerMovieInput?.value) || 1;
|
||||
const pointsPerCorrectPick = parseInt(pointsPerCorrectPickInput?.value) || 1;
|
||||
|
||||
if (pointsPerMovie < 0 || pointsPerCorrectPick < 0) {
|
||||
if (pointsConfigAlert) {
|
||||
pointsConfigAlert.className = 'alert alert-warning';
|
||||
pointsConfigAlert.textContent = 'Les points doivent être positifs ou nuls';
|
||||
pointsConfigAlert.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pointsConfigSaveBtn.disabled = true;
|
||||
const oldText = pointsConfigSaveBtn.textContent;
|
||||
pointsConfigSaveBtn.textContent = 'Enregistrement...';
|
||||
|
||||
const res = await fetch('/api/settings/points-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pointsPerMovie,
|
||||
pointsPerCorrectPick
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Failed to save');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
updatePointsPreview();
|
||||
showResponse('success', `Configuration enregistrée: ${data.pointsPerMovie} pts/film, ${data.pointsPerCorrectPick} pts/bonne réponse`);
|
||||
} catch (err) {
|
||||
if (pointsConfigAlert) {
|
||||
pointsConfigAlert.className = 'alert alert-danger';
|
||||
pointsConfigAlert.textContent = 'Erreur: ' + err.message;
|
||||
pointsConfigAlert.classList.remove('d-none');
|
||||
}
|
||||
} finally {
|
||||
pointsConfigSaveBtn.disabled = false;
|
||||
pointsConfigSaveBtn.textContent = oldText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await loadPointsConfig();
|
||||
|
||||
// Categories management
|
||||
const importPdfForm = document.getElementById('import-pdf-form');
|
||||
const createCategoryForm = document.getElementById('create-category-form');
|
||||
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
|
||||
const categoriesListContainer = document.getElementById('categories-list-container');
|
||||
const listYearFilter = document.getElementById('list-year-filter');
|
||||
|
||||
function showCategoriesAlert(message, type = 'info') {
|
||||
const alertEl = document.getElementById('categories-import-alert') || document.getElementById('categories-manual-alert');
|
||||
if (!alertEl) return;
|
||||
alertEl.className = `alert alert-${type}`;
|
||||
alertEl.textContent = message;
|
||||
alertEl.classList.remove('d-none');
|
||||
setTimeout(() => {
|
||||
alertEl.classList.add('d-none');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
if (importPdfForm) {
|
||||
importPdfForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const year = parseYear(document.getElementById('categories-year').value);
|
||||
const fileInput = document.getElementById('pdf-file');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file || !year) {
|
||||
showCategoriesAlert('Veuillez remplir tous les champs', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf', file);
|
||||
formData.append('year', year);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/categories/import-pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Import failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showCategoriesAlert(`Import réussi: ${data.categories.length} catégories importées`, 'success');
|
||||
fileInput.value = '';
|
||||
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
|
||||
} catch (err) {
|
||||
showCategoriesAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (createCategoryForm) {
|
||||
createCategoryForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const year = parseYear(document.getElementById('manual-year').value);
|
||||
const categoryNumber = parseInt(document.getElementById('category-number').value);
|
||||
const categoryName = document.getElementById('category-name').value.trim();
|
||||
const nomineesText = document.getElementById('nominees-list').value.trim();
|
||||
|
||||
if (!year || !categoryNumber || !categoryName || !nomineesText) {
|
||||
showCategoriesAlert('Veuillez remplir tous les champs', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const nominees = nomineesText.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (nominees.length === 0) {
|
||||
showCategoriesAlert('Veuillez entrer au moins un nommé', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/categories/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
categoryNumber,
|
||||
categoryName,
|
||||
nominees
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Creation failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showCategoriesAlert('Catégorie créée avec succès', 'success');
|
||||
createCategoryForm.reset();
|
||||
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
|
||||
} catch (err) {
|
||||
showCategoriesAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCategoriesList() {
|
||||
if (!categoriesListContainer) return;
|
||||
|
||||
const year = listYearFilter?.value ? parseYear(listYearFilter.value) : null;
|
||||
const url = year ? `/api/categories/list?year=${year}` : '/api/categories/list';
|
||||
|
||||
try {
|
||||
categoriesListContainer.innerHTML = '<div class="text-muted">Chargement…</div>';
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load categories');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const categories = data.categories || [];
|
||||
|
||||
if (categories.length === 0) {
|
||||
categoriesListContainer.innerHTML = `<div class="text-muted">Aucune catégorie trouvée${year ? ` pour l'année ${year}` : ''}.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
categoriesListContainer.innerHTML = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Nom</th>
|
||||
<th>Nommés</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${categories.map(cat => `
|
||||
<tr>
|
||||
<td>${cat.categoryNumber}</td>
|
||||
<td><strong>${cat.categoryName}</strong></td>
|
||||
<td>
|
||||
<ul class="mb-0 small">
|
||||
${cat.nominees.map(n => `<li>${n.name}</li>`).join('')}
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary me-1" onclick="editCategory('${cat._id}')">Éditer</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteCategory('${cat._id}')">Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.editCategory = async function(categoryId) {
|
||||
try {
|
||||
// Fetch category details
|
||||
const res = await fetch(`/api/categories/list`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load categories');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const category = data.categories.find(c => c._id === categoryId);
|
||||
|
||||
if (!category) {
|
||||
showResponse('danger', 'Catégorie non trouvée');
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate modal
|
||||
document.getElementById('edit_category_id').value = categoryId;
|
||||
document.getElementById('edit_category_number').value = category.categoryNumber;
|
||||
document.getElementById('edit_category_name').value = category.categoryName;
|
||||
document.getElementById('edit_nominees_list').value = category.nominees.map(n => n.name).join('\n');
|
||||
|
||||
// Hide alert
|
||||
const alertEl = document.getElementById('edit-category-alert');
|
||||
if (alertEl) {
|
||||
alertEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Show modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('editCategoryModal'));
|
||||
modal.show();
|
||||
} catch (err) {
|
||||
showResponse('danger', 'Erreur: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteCategory = async function(categoryId) {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer cette catégorie?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Delete failed');
|
||||
}
|
||||
|
||||
showResponse('success', 'Catégorie supprimée');
|
||||
await loadCategoriesList();
|
||||
} catch (err) {
|
||||
showResponse('danger', 'Erreur: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Save category changes
|
||||
const saveCategoryChangesBtn = document.getElementById('save-category-changes');
|
||||
if (saveCategoryChangesBtn) {
|
||||
saveCategoryChangesBtn.addEventListener('click', async () => {
|
||||
const categoryId = document.getElementById('edit_category_id').value;
|
||||
const categoryName = document.getElementById('edit_category_name').value.trim();
|
||||
const nomineesText = document.getElementById('edit_nominees_list').value.trim();
|
||||
const alertEl = document.getElementById('edit-category-alert');
|
||||
|
||||
if (!categoryName || !nomineesText) {
|
||||
if (alertEl) {
|
||||
alertEl.className = 'alert alert-warning';
|
||||
alertEl.textContent = 'Veuillez remplir tous les champs';
|
||||
alertEl.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nominees = nomineesText.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (nominees.length === 0) {
|
||||
if (alertEl) {
|
||||
alertEl.className = 'alert alert-warning';
|
||||
alertEl.textContent = 'Veuillez entrer au moins un nommé';
|
||||
alertEl.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveCategoryChangesBtn.disabled = true;
|
||||
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
|
||||
saveCategoryChangesBtn.textContent = 'Enregistrement...';
|
||||
|
||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
categoryName,
|
||||
nominees
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Update failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showResponse('success', 'Catégorie mise à jour avec succès');
|
||||
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editCategoryModal'));
|
||||
if (modal) modal.hide();
|
||||
|
||||
// Reload list
|
||||
await loadCategoriesList();
|
||||
} catch (err) {
|
||||
if (alertEl) {
|
||||
alertEl.className = 'alert alert-danger';
|
||||
alertEl.textContent = 'Erreur: ' + err.message;
|
||||
alertEl.classList.remove('d-none');
|
||||
}
|
||||
} finally {
|
||||
saveCategoryChangesBtn.disabled = false;
|
||||
saveCategoryChangesBtn.textContent = oldText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshCategoriesListBtn) {
|
||||
refreshCategoriesListBtn.addEventListener('click', loadCategoriesList);
|
||||
|
||||
// Auto-reload when year filter changes
|
||||
if (listYearFilter) {
|
||||
listYearFilter.addEventListener('change', loadCategoriesList);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-load when tab is shown
|
||||
const categoriesTabBtn = document.getElementById('admin-categories-tab');
|
||||
if (categoriesTabBtn) {
|
||||
categoriesTabBtn.addEventListener('shown.bs.tab', () => {
|
||||
loadCategoriesList();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = decodeJwt(token);
|
||||
if (!decoded) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if admin and show admin tabs
|
||||
if (decoded.admin) {
|
||||
const adminTabAllPicks = document.getElementById('tab-all-picks');
|
||||
if (adminTabAllPicks) adminTabAllPicks.classList.remove('d-none');
|
||||
const adminTabWinners = document.getElementById('tab-winners');
|
||||
if (adminTabWinners) adminTabWinners.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Logout
|
||||
const logOffBtn = document.getElementById('log-off');
|
||||
if (logOffBtn) {
|
||||
logOffBtn.addEventListener('click', () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
let categories = [];
|
||||
let myPicks = {};
|
||||
let currentYear = null;
|
||||
|
||||
async function fetchActiveYear() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/year');
|
||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||
const data = await res.json();
|
||||
return Number(data.year) || new Date().getFullYear();
|
||||
} catch (err) {
|
||||
console.error('Error fetching active year:', err);
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message, type = 'info') {
|
||||
const container = document.getElementById('alert-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
const alert = container.querySelector('.alert');
|
||||
if (alert) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
// Use active year if not set
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch categories');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
categories = data.categories || [];
|
||||
currentYear = data.year || currentYear;
|
||||
|
||||
document.getElementById('current-year').textContent = currentYear;
|
||||
if (document.getElementById('all-picks-year')) {
|
||||
document.getElementById('all-picks-year').textContent = currentYear;
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
document.getElementById('categories-container').innerHTML = `
|
||||
<div class="alert alert-warning">
|
||||
Aucune catégorie trouvée pour l'année ${currentYear}.
|
||||
Un administrateur doit d'abord importer les catégories.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
renderCategories();
|
||||
await loadMyPicks();
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyPicks() {
|
||||
try {
|
||||
const res = await fetch(`/api/picks/my-picks?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error('Failed to load picks');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.pick && data.pick.picks) {
|
||||
myPicks = {};
|
||||
data.pick.picks.forEach(p => {
|
||||
myPicks[p.categoryNumber] = p.selectedNominee;
|
||||
});
|
||||
renderCategories();
|
||||
updateProgress();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading picks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCategories() {
|
||||
const container = document.getElementById('categories-container');
|
||||
if (!container) return;
|
||||
|
||||
if (categories.length === 0) {
|
||||
container.innerHTML = '<div class="text-center py-4 text-muted">Aucune catégorie disponible</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = categories.map(cat => `
|
||||
<div class="card category-card shadow-sm">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">${cat.categoryNumber}. ${cat.categoryName}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
${cat.nominees.map(nominee => {
|
||||
const isSelected = myPicks[cat.categoryNumber] === nominee.name;
|
||||
return `
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<div class="nominee-option p-3 border rounded ${isSelected ? 'selected' : ''}"
|
||||
data-category="${cat.categoryNumber}"
|
||||
data-nominee="${nominee.name}">
|
||||
${nominee.name}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
container.querySelectorAll('.nominee-option').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const category = parseInt(this.dataset.category);
|
||||
const nominee = this.dataset.nominee;
|
||||
|
||||
// Remove selection from other options in same category
|
||||
container.querySelectorAll(`[data-category="${category}"]`).forEach(opt => {
|
||||
opt.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Select this option
|
||||
this.classList.add('selected');
|
||||
myPicks[category] = nominee;
|
||||
updateProgress();
|
||||
});
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const total = categories.length;
|
||||
const completed = Object.keys(myPicks).length;
|
||||
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
|
||||
}
|
||||
|
||||
async function submitPicks() {
|
||||
const picks = Object.keys(myPicks).map(catNum => ({
|
||||
categoryNumber: parseInt(catNum),
|
||||
selectedNominee: myPicks[catNum]
|
||||
}));
|
||||
|
||||
if (picks.length === 0) {
|
||||
showAlert('Veuillez sélectionner au moins un choix', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (picks.length < categories.length) {
|
||||
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/picks/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
year: currentYear,
|
||||
picks
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Failed to submit picks');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
||||
} catch (err) {
|
||||
console.error('Error submitting picks:', err);
|
||||
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// All picks functionality (admin only)
|
||||
let allPicks = [];
|
||||
let allPicksCategories = [];
|
||||
|
||||
async function fetchAllPicks() {
|
||||
try {
|
||||
// Use active year if not set
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/picks/all?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
showAlert('Vous n\'avez pas les privilèges administrateur', 'warning');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch picks');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
allPicks = data.picks || [];
|
||||
allPicksCategories = data.categories || [];
|
||||
currentYear = data.year || currentYear;
|
||||
|
||||
const yearEl = document.getElementById('all-picks-year');
|
||||
if (yearEl) yearEl.textContent = currentYear;
|
||||
|
||||
const countEl = document.getElementById('users-count');
|
||||
if (countEl) countEl.textContent = allPicks.length;
|
||||
|
||||
renderAllPicks();
|
||||
} catch (err) {
|
||||
console.error('Error fetching all picks:', err);
|
||||
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllPicks() {
|
||||
const container = document.getElementById('all-picks-container');
|
||||
if (!container) return;
|
||||
|
||||
if (allPicks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of category info
|
||||
const categoryMap = new Map();
|
||||
allPicksCategories.forEach(cat => {
|
||||
categoryMap.set(cat.categoryNumber, cat);
|
||||
});
|
||||
|
||||
container.innerHTML = allPicks.map(pick => {
|
||||
const userName = pick.userId?.name || 'Utilisateur inconnu';
|
||||
const userEmail = pick.userId?.email || '';
|
||||
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
|
||||
|
||||
// Create a map of picks by category
|
||||
const picksMap = new Map();
|
||||
pick.picks.forEach(p => {
|
||||
picksMap.set(p.categoryNumber, p.selectedNominee);
|
||||
});
|
||||
|
||||
// Build picks list
|
||||
const picksList = allPicksCategories.map(cat => {
|
||||
const selected = picksMap.get(cat.categoryNumber);
|
||||
if (!selected) return null;
|
||||
|
||||
// Check if it's a winner (if winners are set)
|
||||
const nominee = cat.nominees.find(n => n.name === selected);
|
||||
const isWinner = nominee?.isWinner || false;
|
||||
|
||||
return `
|
||||
<div class="pick-item ${isWinner ? 'correct-pick' : ''}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
|
||||
<span class="text-muted">${selected}</span>
|
||||
</div>
|
||||
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
return `
|
||||
<div class="card user-picks-card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">${userName}</h5>
|
||||
<small class="text-muted">${userEmail}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
|
||||
<span class="badge bg-primary">${pick.picks.length} / ${allPicksCategories.length} catégories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
|
||||
document.getElementById('load-my-picks')?.addEventListener('click', () => {
|
||||
loadMyPicks();
|
||||
showAlert('Choix rechargés', 'info');
|
||||
});
|
||||
document.getElementById('refresh-all-picks')?.addEventListener('click', () => {
|
||||
fetchAllPicks();
|
||||
showAlert('Choix rafraîchis', 'info');
|
||||
});
|
||||
|
||||
// Load all picks when admin tab is shown
|
||||
const allPicksTab = document.getElementById('tab-all-picks');
|
||||
if (allPicksTab) {
|
||||
allPicksTab.addEventListener('shown.bs.tab', () => {
|
||||
fetchAllPicks();
|
||||
});
|
||||
}
|
||||
|
||||
// Winners & Scores functionality (admin only)
|
||||
let winnersCategories = [];
|
||||
|
||||
async function fetchWinnersCategories() {
|
||||
try {
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch categories');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
winnersCategories = data.categories || [];
|
||||
currentYear = data.year || currentYear;
|
||||
|
||||
const yearEl = document.getElementById('winners-year');
|
||||
if (yearEl) yearEl.textContent = currentYear;
|
||||
|
||||
renderWinnersCategories();
|
||||
await fetchScores();
|
||||
} catch (err) {
|
||||
console.error('Error fetching winners categories:', err);
|
||||
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderWinnersCategories() {
|
||||
const container = document.getElementById('winners-container');
|
||||
if (!container) return;
|
||||
|
||||
if (winnersCategories.length === 0) {
|
||||
container.innerHTML = '<div class="alert alert-warning">Aucune catégorie trouvée pour cette année.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const winnersCount = winnersCategories.filter(cat =>
|
||||
cat.nominees.some(n => n.isWinner)
|
||||
).length;
|
||||
|
||||
const progressEl = document.getElementById('winners-progress');
|
||||
if (progressEl) progressEl.textContent = `${winnersCount} / ${winnersCategories.length}`;
|
||||
|
||||
container.innerHTML = winnersCategories.map(cat => {
|
||||
const winner = cat.nominees.find(n => n.isWinner);
|
||||
return `
|
||||
<div class="card category-winner-card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">${cat.categoryNumber}. ${cat.categoryName}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label fw-semibold">Gagnant:</label>
|
||||
<select class="form-select winner-select" data-category-id="${cat._id}" data-category-number="${cat.categoryNumber}">
|
||||
<option value="">-- Sélectionner un gagnant --</option>
|
||||
${cat.nominees.map(nominee => `
|
||||
<option value="${nominee.name}" ${nominee.isWinner ? 'selected' : ''}>${nominee.name}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
${winner ? `<div class="alert alert-success mb-0 mt-2"><strong>Gagnant actuel:</strong> ${winner.name}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners for winner selection
|
||||
container.querySelectorAll('.winner-select').forEach(select => {
|
||||
select.addEventListener('change', async function() {
|
||||
const categoryId = this.dataset.categoryId;
|
||||
const winnerName = this.value;
|
||||
|
||||
if (!winnerName) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/categories/${categoryId}/winner`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ winnerName })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Failed to mark winner');
|
||||
}
|
||||
|
||||
showAlert('Gagnant marqué avec succès', 'success');
|
||||
await fetchWinnersCategories();
|
||||
} catch (err) {
|
||||
console.error('Error marking winner:', err);
|
||||
showAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function calculateScores() {
|
||||
try {
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
}
|
||||
|
||||
const btn = document.getElementById('calculate-scores');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Calcul en cours...';
|
||||
}
|
||||
|
||||
const res = await fetch('/api/picks/calculate-scores', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ year: currentYear })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || 'Failed to calculate scores');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
|
||||
await fetchScores();
|
||||
} catch (err) {
|
||||
console.error('Error calculating scores:', err);
|
||||
showAlert('Erreur: ' + err.message, 'danger');
|
||||
} finally {
|
||||
const btn = document.getElementById('calculate-scores');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Calculer les scores';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScores() {
|
||||
try {
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/picks/scores?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
return; // Not admin, skip
|
||||
}
|
||||
throw new Error('Failed to fetch scores');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
renderScores(data.scores || [], data.totalCategories || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching scores:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderScores(scores, totalCategories) {
|
||||
const container = document.getElementById('scores-container');
|
||||
if (!container) return;
|
||||
|
||||
if (scores.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted">Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Utilisateur</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th class="text-center">Total</th>
|
||||
<th class="text-center">%</th>
|
||||
<th class="text-center" style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${scores.map((score, index) => {
|
||||
const percentage = totalCategories > 0 ? Math.round((score.score / totalCategories) * 100) : 0;
|
||||
const rank = index + 1;
|
||||
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${rank}${medal ? ' ' + medal : ''}</strong></td>
|
||||
<td>${score.userName}${score.userEmail ? ` <small class="text-muted">(${score.userEmail})</small>` : ''}</td>
|
||||
<td class="text-center"><strong class="text-success">${score.score}</strong></td>
|
||||
<td class="text-center text-muted">${totalCategories}</td>
|
||||
<td class="text-center"><strong>${percentage}%</strong></td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
|
||||
Voir détails
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store scores data globally for modal access
|
||||
window.scoresData = scores;
|
||||
|
||||
// Add event listeners for view picks buttons
|
||||
container.querySelectorAll('.view-picks-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const scoreIndex = parseInt(this.dataset.scoreIndex);
|
||||
const score = scores[scoreIndex];
|
||||
showUserPicksModal(score);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showUserPicksModal(score) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('userPicksModal'));
|
||||
const modalTitle = document.getElementById('userPicksModalLabel');
|
||||
const modalContent = document.getElementById('user-picks-modal-content');
|
||||
|
||||
modalTitle.textContent = `Choix de ${score.userName}`;
|
||||
|
||||
if (!score.pickDetails || score.pickDetails.length === 0) {
|
||||
modalContent.innerHTML = '<div class="text-muted">Aucun détail disponible.</div>';
|
||||
modal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Separate correct and incorrect picks
|
||||
const correctPicks = score.pickDetails.filter(p => p.isCorrect);
|
||||
const incorrectPicks = score.pickDetails.filter(p => !p.isCorrect);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-1">Résumé</h6>
|
||||
<div class="text-muted small">
|
||||
<span class="text-success"><strong>${correctPicks.length}</strong> correct(s)</span> |
|
||||
<span class="text-danger"><strong>${incorrectPicks.length}</strong> incorrect(s)</span> |
|
||||
<span class="text-muted"><strong>${score.pickDetails.length}</strong> total</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="h4 mb-0 text-success">${score.score} / ${score.totalCategories}</div>
|
||||
<div class="text-muted small">${Math.round((score.score / score.totalCategories) * 100)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${correctPicks.length > 0 ? `
|
||||
<div class="mb-4">
|
||||
<h6 class="text-success mb-3">
|
||||
<span class="badge bg-success me-2">✓</span>
|
||||
Corrects (${correctPicks.length})
|
||||
</h6>
|
||||
<div class="list-group">
|
||||
${correctPicks.map(pick => `
|
||||
<div class="list-group-item list-group-item-success">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
|
||||
<div class="mt-1">
|
||||
<span class="badge bg-success">✓ ${pick.selectedNominee}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${incorrectPicks.length > 0 ? `
|
||||
<div class="mb-4">
|
||||
<h6 class="text-danger mb-3">
|
||||
<span class="badge bg-danger me-2">✗</span>
|
||||
Incorrects (${incorrectPicks.length})
|
||||
</h6>
|
||||
<div class="list-group">
|
||||
${incorrectPicks.map(pick => `
|
||||
<div class="list-group-item list-group-item-danger">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
|
||||
<div class="mt-2">
|
||||
<div class="mb-1">
|
||||
<span class="badge bg-danger">✗ Choix: ${pick.selectedNominee}</span>
|
||||
</div>
|
||||
${pick.correctWinner ? `
|
||||
<div>
|
||||
<span class="badge bg-success">✓ Gagnant: ${pick.correctWinner}</span>
|
||||
</div>
|
||||
` : '<div class="text-muted small">Aucun gagnant marqué</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
|
||||
<div class="alert alert-info">
|
||||
Aucun choix à afficher.
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Event listeners for winners tab
|
||||
document.getElementById('calculate-scores')?.addEventListener('click', calculateScores);
|
||||
document.getElementById('refresh-winners')?.addEventListener('click', () => {
|
||||
fetchWinnersCategories();
|
||||
showAlert('Données rafraîchies', 'info');
|
||||
});
|
||||
|
||||
// Load winners when admin tab is shown
|
||||
const winnersTab = document.getElementById('tab-winners');
|
||||
if (winnersTab) {
|
||||
winnersTab.addEventListener('shown.bs.tab', () => {
|
||||
fetchWinnersCategories();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
(async () => {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||
await fetchCategories();
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -650,14 +650,24 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const stats = await statsRes.json();
|
||||
const userTableBody = document.getElementById('user-table-body');
|
||||
|
||||
// Ensure watchedRatio is treated as a number and sort in descending order
|
||||
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
||||
// Sort by total points (descending), then by watched ratio
|
||||
stats.sort((a, b) => {
|
||||
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
|
||||
if (pointsDiff !== 0) return pointsDiff;
|
||||
return parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio);
|
||||
});
|
||||
|
||||
// Clear previous rows to prevent duplication
|
||||
userTableBody.innerHTML = '';
|
||||
|
||||
stats.forEach(userStat => {
|
||||
stats.forEach((userStat, index) => {
|
||||
const userRow = document.createElement('tr');
|
||||
const rank = index + 1;
|
||||
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
||||
|
||||
const rankTd = document.createElement('td');
|
||||
rankTd.innerHTML = `<strong>${rank}${medal ? ' ' + medal : ''}</strong>`;
|
||||
|
||||
const nameTd = document.createElement('td');
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
@@ -670,13 +680,25 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
nameTd.appendChild(link);
|
||||
|
||||
const countTd = document.createElement('td');
|
||||
countTd.textContent = String(userStat.watchedCount ?? '');
|
||||
const pointsConfig = userStat.pointsConfig || { pointsPerMovie: 1 };
|
||||
countTd.innerHTML = `${userStat.watchedCount ?? 0} <small class="text-muted">(${userStat.moviePoints || 0} pts)</small>`;
|
||||
|
||||
const picksTd = document.createElement('td');
|
||||
const pickPointsConfig = userStat.pointsConfig || { pointsPerCorrectPick: 1 };
|
||||
picksTd.innerHTML = `${userStat.correctPicks || 0} <small class="text-muted">(${userStat.pickPoints || 0} pts)</small>`;
|
||||
|
||||
const pointsTd = document.createElement('td');
|
||||
pointsTd.className = 'text-center';
|
||||
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
|
||||
|
||||
const ratioTd = document.createElement('td');
|
||||
ratioTd.textContent = String(userStat.watchedRatio ?? '');
|
||||
|
||||
userRow.appendChild(rankTd);
|
||||
userRow.appendChild(nameTd);
|
||||
userRow.appendChild(countTd);
|
||||
userRow.appendChild(picksTd);
|
||||
userRow.appendChild(pointsTd);
|
||||
userRow.appendChild(ratioTd);
|
||||
userTableBody.appendChild(userRow);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = decodeJwt(token);
|
||||
if (!decoded) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if admin
|
||||
if (!decoded.admin) {
|
||||
window.location.href = '/picks.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Logout
|
||||
const logOffBtn = document.getElementById('log-off');
|
||||
if (logOffBtn) {
|
||||
logOffBtn.addEventListener('click', () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
});
|
||||
}
|
||||
|
||||
let allPicks = [];
|
||||
let categories = [];
|
||||
let currentYear = new Date().getFullYear();
|
||||
|
||||
function showAlert(message, type = 'info') {
|
||||
const container = document.getElementById('alert-container');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
const alert = container.querySelector('.alert');
|
||||
if (alert) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function fetchAllPicks() {
|
||||
try {
|
||||
const res = await fetch(`/api/picks/all?year=${currentYear}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
window.location.href = '/picks.html';
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch picks');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
allPicks = data.picks || [];
|
||||
categories = data.categories || [];
|
||||
currentYear = data.year || currentYear;
|
||||
|
||||
document.getElementById('current-year').textContent = currentYear;
|
||||
document.getElementById('users-count').textContent = allPicks.length;
|
||||
|
||||
renderPicks();
|
||||
} catch (err) {
|
||||
console.error('Error fetching picks:', err);
|
||||
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPicks() {
|
||||
const container = document.getElementById('picks-container');
|
||||
if (!container) return;
|
||||
|
||||
if (allPicks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of category info
|
||||
const categoryMap = new Map();
|
||||
categories.forEach(cat => {
|
||||
categoryMap.set(cat.categoryNumber, cat);
|
||||
});
|
||||
|
||||
container.innerHTML = allPicks.map(pick => {
|
||||
const userName = pick.userId?.name || 'Utilisateur inconnu';
|
||||
const userEmail = pick.userId?.email || '';
|
||||
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
|
||||
|
||||
// Create a map of picks by category
|
||||
const picksMap = new Map();
|
||||
pick.picks.forEach(p => {
|
||||
picksMap.set(p.categoryNumber, p.selectedNominee);
|
||||
});
|
||||
|
||||
// Build picks list
|
||||
const picksList = categories.map(cat => {
|
||||
const selected = picksMap.get(cat.categoryNumber);
|
||||
if (!selected) return null;
|
||||
|
||||
// Check if it's a winner (if winners are set)
|
||||
const nominee = cat.nominees.find(n => n.name === selected);
|
||||
const isWinner = nominee?.isWinner || false;
|
||||
|
||||
return `
|
||||
<div class="pick-item ${isWinner ? 'correct-pick' : ''}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
|
||||
<span class="text-muted">${selected}</span>
|
||||
</div>
|
||||
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
return `
|
||||
<div class="card user-picks-card shadow-sm">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="mb-0">${userName}</h5>
|
||||
<small class="text-muted">${userEmail}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
|
||||
<span class="badge bg-primary">${pick.picks.length} / ${categories.length} catégories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('refresh-picks')?.addEventListener('click', () => {
|
||||
fetchAllPicks();
|
||||
showAlert('Choix rafraîchis', 'info');
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchAllPicks();
|
||||
})();
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||
</div>
|
||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars - Mes choix</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
<link rel="preload" as="image" href="img/logo_w.png">
|
||||
<script src="./js/app_header_boot.js" defer></script>
|
||||
<style>
|
||||
.navbar-custom {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
.navbar-custom .navbar-brand,
|
||||
.navbar-custom .nav-link {
|
||||
color: white;
|
||||
}
|
||||
.navbar-custom .nav-link:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
.navbar-toggler {
|
||||
border-color: white;
|
||||
}
|
||||
.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");
|
||||
}
|
||||
.category-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.nominee-option {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.nominee-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.nominee-option.selected {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.nominee-option.selected:hover {
|
||||
background-color: #0b5ed7;
|
||||
}
|
||||
.user-picks-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.pick-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.pick-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.correct-pick {
|
||||
background-color: #d1e7dd;
|
||||
}
|
||||
.incorrect-pick {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" style="margin-left: -7px;">
|
||||
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<div class="navbar-nav">
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||
</div>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
<span id="user-name" class="navbar-text me-3"></span>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5 pb-5">
|
||||
<h2>Mes choix Oscars</h2>
|
||||
|
||||
<div id="alert-container"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs mt-3" id="picksTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-my-picks" data-bs-toggle="tab" data-bs-target="#pane-my-picks" type="button" role="tab" aria-controls="pane-my-picks" aria-selected="true">
|
||||
Mes choix
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link d-none" id="tab-all-picks" data-bs-toggle="tab" data-bs-target="#pane-all-picks" type="button" role="tab" aria-controls="pane-all-picks" aria-selected="false">
|
||||
Voir tous les choix
|
||||
<span class="badge bg-warning text-dark ms-1">Admin</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link d-none" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
|
||||
Gagnants & Scores
|
||||
<span class="badge bg-warning text-dark ms-1">Admin</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content pt-3" id="picksTabsContent">
|
||||
<!-- My Picks Tab -->
|
||||
<div class="tab-pane fade show active" id="pane-my-picks" role="tabpanel" aria-labelledby="tab-my-picks" tabindex="0">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Année: <span id="current-year">—</span></div>
|
||||
<div class="text-muted small">Sélectionne tes choix pour chaque catégorie</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="load-my-picks" class="btn btn-outline-secondary">Recharger mes choix</button>
|
||||
<button type="button" id="submit-picks" class="btn btn-primary">Enregistrer mes choix</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small">
|
||||
Progression: <span id="picks-progress">0 / 0</span> catégories complétées
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categories-container">
|
||||
<div class="text-center py-4 text-muted">Chargement des catégories…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Picks Tab (Admin only) -->
|
||||
<div class="tab-pane fade" id="pane-all-picks" role="tabpanel" aria-labelledby="tab-all-picks" tabindex="0">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Année: <span id="all-picks-year">—</span></div>
|
||||
<div class="text-muted small">Vue d'ensemble de tous les choix des utilisateurs</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="refresh-all-picks" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small">
|
||||
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="all-picks-container">
|
||||
<div class="text-center py-4 text-muted">Chargement des choix…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Winners & Scores Tab (Admin only) -->
|
||||
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Année: <span id="winners-year">—</span></div>
|
||||
<div class="text-muted small">Marquez les gagnants et calculez les scores</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="calculate-scores" class="btn btn-success">Calculer les scores</button>
|
||||
<button type="button" id="refresh-winners" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small">
|
||||
<span id="winners-progress">0 / 0</span> catégories avec gagnants marqués
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="winners-container">
|
||||
<div class="text-center py-4 text-muted">Chargement des catégories…</div>
|
||||
</div>
|
||||
|
||||
<!-- Scores Table -->
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Scores des utilisateurs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="scores-container">
|
||||
<div class="text-center py-4 text-muted">Les scores seront calculés après avoir marqué les gagnants.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for user picks comparison -->
|
||||
<div class="modal fade" id="userPicksModal" tabindex="-1" aria-labelledby="userPicksModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userPicksModalLabel">Détails des choix</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="user-picks-modal-content">
|
||||
<div class="text-center py-4 text-muted">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
|
||||
<script src="./js/app_picks.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||
</div>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2">
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||
</div>
|
||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||
@@ -108,8 +109,11 @@
|
||||
<table class="table table-striped table-hover align-middle user-table d-none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Total des films regardés (pts)</th>
|
||||
<th scope="col">Films</th>
|
||||
<th scope="col">Bonnes réponses</th>
|
||||
<th scope="col">Points</th>
|
||||
<th scope="col">Ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars - Voir Tous les Choix</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
<link rel="preload" as="image" href="img/logo_w.png">
|
||||
<script src="./js/app_header_boot.js" defer></script>
|
||||
<style>
|
||||
.navbar-custom {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
.navbar-custom .navbar-brand,
|
||||
.navbar-custom .nav-link {
|
||||
color: white;
|
||||
}
|
||||
.navbar-custom .nav-link:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
.navbar-toggler {
|
||||
border-color: white;
|
||||
}
|
||||
.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");
|
||||
}
|
||||
.user-picks-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.pick-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
.pick-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.correct-pick {
|
||||
background-color: #d1e7dd;
|
||||
}
|
||||
.incorrect-pick {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" style="margin-left: -7px;">
|
||||
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<div class="navbar-nav">
|
||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
|
||||
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
|
||||
<a href="view-picks.html" class="nav-link btn btn-info me-2 active">Voir Tous les Choix</a>
|
||||
</div>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
<span id="user-name" class="navbar-text me-3"></span>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5 pb-5">
|
||||
<h2>Voir Tous les Choix</h2>
|
||||
|
||||
<div id="alert-container"></div>
|
||||
|
||||
<div class="card shadow-sm mt-3 mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Année: <span id="current-year">—</span></div>
|
||||
<div class="text-muted small">Vue d'ensemble de tous les choix des utilisateurs</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="refresh-picks" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small">
|
||||
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="picks-container">
|
||||
<div class="text-center py-4 text-muted">Chargement des choix…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
|
||||
<script src="./js/app_view-picks.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user