Merge 2026 branch into prod #69
Binary file not shown.
Generated
+4
-4
@@ -1637,7 +1637,7 @@
|
|||||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdf-parse/node_modules/pdfjs-dist": {
|
||||||
"version": "5.4.296",
|
"version": "5.4.296",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||||
@@ -1790,9 +1790,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.6.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
|
|||||||
+15
-60
@@ -892,18 +892,13 @@
|
|||||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-1">Catégories Oscars</h4>
|
<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 class="text-muted">Créez et gérez les catégories manuellement.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCategoriesSubTabs" role="tablist">
|
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCategoriesSubTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<button class="nav-link active" 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="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
|
Création manuelle
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -915,54 +910,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content pt-3">
|
<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 -->
|
<!-- Manual creation -->
|
||||||
<div class="tab-pane fade" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
|
<div class="tab-pane fade show active" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-xxl-7">
|
<div class="col-12 col-xxl-7">
|
||||||
<div class="border rounded p-3 bg-light">
|
<div class="border rounded p-3 bg-light">
|
||||||
@@ -1023,11 +972,17 @@
|
|||||||
<div class="fw-semibold">Catégories importées</div>
|
<div class="fw-semibold">Catégories importées</div>
|
||||||
<div class="text-muted small">Gérez les catégories existantes</div>
|
<div class="text-muted small">Gérez les catégories existantes</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
<select class="form-select" id="list-year-filter" style="width: 150px;">
|
<div class="d-flex gap-2">
|
||||||
<option value="">Toutes les années</option>
|
<select class="form-select" id="list-year-filter" style="width: 150px;">
|
||||||
</select>
|
<option value="">Toutes les années</option>
|
||||||
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
|
</select>
|
||||||
|
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||||
|
<button type="button" id="bulk-delete-categories-btn" class="btn btn-sm btn-danger" disabled>Supprimer</button>
|
||||||
|
</div>
|
||||||
|
<div id="categories-selection-text" class="text-muted small" style="display: none;">
|
||||||
|
<span id="bulk-delete-count">0</span> catégorie(s) sélectionnée(s)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1279,7 +1234,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="edit_category_number" class="form-label">Numéro de catégorie</label>
|
<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>
|
<input type="number" class="form-control" id="edit_category_number" min="1" max="50" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
+143
-74
@@ -2096,38 +2096,9 @@ window.onload = async function () {
|
|||||||
applyWinnerFormFromCache();
|
applyWinnerFormFromCache();
|
||||||
|
|
||||||
// Categories year dropdowns
|
// Categories year dropdowns
|
||||||
const categoriesYearSelect = document.getElementById('categories-year');
|
|
||||||
const manualYearSelect = document.getElementById('manual-year');
|
const manualYearSelect = document.getElementById('manual-year');
|
||||||
const listYearFilterSelect = document.getElementById('list-year-filter');
|
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
|
// Populate manual creation year dropdown
|
||||||
if (manualYearSelect) {
|
if (manualYearSelect) {
|
||||||
const previousManualYear = manualYearSelect.value;
|
const previousManualYear = manualYearSelect.value;
|
||||||
@@ -2761,14 +2732,13 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Categories management
|
// Categories management
|
||||||
const importPdfForm = document.getElementById('import-pdf-form');
|
|
||||||
const createCategoryForm = document.getElementById('create-category-form');
|
const createCategoryForm = document.getElementById('create-category-form');
|
||||||
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
|
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
|
||||||
const categoriesListContainer = document.getElementById('categories-list-container');
|
const categoriesListContainer = document.getElementById('categories-list-container');
|
||||||
const listYearFilter = document.getElementById('list-year-filter');
|
const listYearFilter = document.getElementById('list-year-filter');
|
||||||
|
|
||||||
function showCategoriesAlert(message, type = 'info') {
|
function showCategoriesAlert(message, type = 'info') {
|
||||||
const alertEl = document.getElementById('categories-import-alert') || document.getElementById('categories-manual-alert');
|
const alertEl = document.getElementById('categories-manual-alert');
|
||||||
if (!alertEl) return;
|
if (!alertEl) return;
|
||||||
alertEl.className = `alert alert-${type}`;
|
alertEl.className = `alert alert-${type}`;
|
||||||
alertEl.textContent = message;
|
alertEl.textContent = message;
|
||||||
@@ -2778,44 +2748,6 @@ window.onload = async function () {
|
|||||||
}, 5000);
|
}, 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) {
|
if (createCategoryForm) {
|
||||||
createCategoryForm.addEventListener('submit', async (e) => {
|
createCategoryForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2897,6 +2829,9 @@ window.onload = async function () {
|
|||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 40px;">
|
||||||
|
<input type="checkbox" class="form-check-input" id="select-all-categories" title="Sélectionner tout">
|
||||||
|
</th>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Nommés</th>
|
<th>Nommés</th>
|
||||||
@@ -2906,6 +2841,9 @@ window.onload = async function () {
|
|||||||
<tbody>
|
<tbody>
|
||||||
${categories.map(cat => `
|
${categories.map(cat => `
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input category-checkbox" data-category-id="${cat._id}" data-category-name="${cat.categoryName}">
|
||||||
|
</td>
|
||||||
<td>${cat.categoryNumber}</td>
|
<td>${cat.categoryNumber}</td>
|
||||||
<td><strong>${cat.categoryName}</strong></td>
|
<td><strong>${cat.categoryName}</strong></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -2923,6 +2861,9 @@ window.onload = async function () {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Setup bulk selection handlers
|
||||||
|
setupBulkCategorySelection();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
|
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
|
||||||
}
|
}
|
||||||
@@ -2930,17 +2871,21 @@ window.onload = async function () {
|
|||||||
|
|
||||||
window.editCategory = async function(categoryId) {
|
window.editCategory = async function(categoryId) {
|
||||||
try {
|
try {
|
||||||
// Fetch category details
|
// Fetch category directly by ID
|
||||||
const res = await fetch(`/api/categories/list`, {
|
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Failed to load categories');
|
if (res.status === 404) {
|
||||||
|
showResponse('danger', 'Catégorie non trouvée');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to load category');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const category = data.categories.find(c => c._id === categoryId);
|
const category = data.category;
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
showResponse('danger', 'Catégorie non trouvée');
|
showResponse('danger', 'Catégorie non trouvée');
|
||||||
@@ -3019,11 +2964,28 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryNumber = parseInt(document.getElementById('edit_category_number').value);
|
||||||
|
if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) {
|
||||||
|
if (alertEl) {
|
||||||
|
alertEl.className = 'alert alert-warning';
|
||||||
|
alertEl.textContent = 'Le numéro de catégorie doit être entre 1 et 50';
|
||||||
|
alertEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store button state before async operation
|
||||||
|
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saveCategoryChangesBtn.disabled = true;
|
saveCategoryChangesBtn.disabled = true;
|
||||||
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
|
|
||||||
saveCategoryChangesBtn.textContent = 'Enregistrement...';
|
saveCategoryChangesBtn.textContent = 'Enregistrement...';
|
||||||
|
|
||||||
|
// Hide any previous alerts
|
||||||
|
if (alertEl) {
|
||||||
|
alertEl.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/categories/${categoryId}`, {
|
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -3031,6 +2993,7 @@ window.onload = async function () {
|
|||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
categoryNumber,
|
||||||
categoryName,
|
categoryName,
|
||||||
nominees
|
nominees
|
||||||
})
|
})
|
||||||
@@ -3051,12 +3014,19 @@ window.onload = async function () {
|
|||||||
// Reload list
|
// Reload list
|
||||||
await loadCategoriesList();
|
await loadCategoriesList();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Show error in modal alert
|
||||||
if (alertEl) {
|
if (alertEl) {
|
||||||
alertEl.className = 'alert alert-danger';
|
alertEl.className = 'alert alert-danger';
|
||||||
alertEl.textContent = 'Erreur: ' + err.message;
|
alertEl.textContent = 'Erreur: ' + err.message;
|
||||||
alertEl.classList.remove('d-none');
|
alertEl.classList.remove('d-none');
|
||||||
|
// Scroll to alert to make it visible
|
||||||
|
alertEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
} else {
|
||||||
|
// Fallback to main response if alert element not found
|
||||||
|
showResponse('danger', 'Erreur: ' + err.message);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
// Always restore button state
|
||||||
saveCategoryChangesBtn.disabled = false;
|
saveCategoryChangesBtn.disabled = false;
|
||||||
saveCategoryChangesBtn.textContent = oldText;
|
saveCategoryChangesBtn.textContent = oldText;
|
||||||
}
|
}
|
||||||
@@ -3079,5 +3049,104 @@ window.onload = async function () {
|
|||||||
loadCategoriesList();
|
loadCategoriesList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk category selection and deletion
|
||||||
|
function setupBulkCategorySelection() {
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all-categories');
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulk-delete-categories-btn');
|
||||||
|
const bulkDeleteCount = document.getElementById('bulk-delete-count');
|
||||||
|
const selectionText = document.getElementById('categories-selection-text');
|
||||||
|
const categoryCheckboxes = document.querySelectorAll('.category-checkbox');
|
||||||
|
|
||||||
|
if (!selectAllCheckbox || !bulkDeleteBtn) return;
|
||||||
|
|
||||||
|
// Select all checkbox handler
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
categoryCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = this.checked;
|
||||||
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Individual checkbox handlers
|
||||||
|
categoryCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
// Update select all checkbox state
|
||||||
|
const allChecked = Array.from(categoryCheckboxes).every(cb => cb.checked);
|
||||||
|
const someChecked = Array.from(categoryCheckboxes).some(cb => cb.checked);
|
||||||
|
selectAllCheckbox.checked = allChecked;
|
||||||
|
selectAllCheckbox.indeterminate = someChecked && !allChecked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update bulk delete button state
|
||||||
|
function updateBulkDeleteButton() {
|
||||||
|
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
|
||||||
|
const count = selected.length;
|
||||||
|
if (bulkDeleteCount) {
|
||||||
|
bulkDeleteCount.textContent = count;
|
||||||
|
}
|
||||||
|
bulkDeleteBtn.disabled = count === 0;
|
||||||
|
if (selectionText) {
|
||||||
|
selectionText.style.display = count > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete handler
|
||||||
|
bulkDeleteBtn.addEventListener('click', async function() {
|
||||||
|
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
const categoryIds = selected.map(cb => cb.dataset.categoryId);
|
||||||
|
const categoryNames = selected.map(cb => cb.dataset.categoryName);
|
||||||
|
|
||||||
|
const confirmMessage = `Êtes-vous sûr de vouloir supprimer ${categoryIds.length} catégorie(s)?\n\n${categoryNames.join('\n')}\n\nCette action est irréversible.`;
|
||||||
|
if (!confirm(confirmMessage)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
bulkDeleteBtn.disabled = true;
|
||||||
|
const oldText = bulkDeleteBtn.textContent;
|
||||||
|
bulkDeleteBtn.textContent = 'Suppression...';
|
||||||
|
|
||||||
|
// Delete categories one by one
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const categoryId of categoryIds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/categories/${categoryId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error deleting category ${categoryId}:`, err);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
showResponse('success', `${successCount} catégorie(s) supprimée(s)${errorCount > 0 ? `, ${errorCount} erreur(s)` : ''}`);
|
||||||
|
await loadCategoriesList();
|
||||||
|
} else {
|
||||||
|
showResponse('danger', `Erreur lors de la suppression: ${errorCount} erreur(s)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showResponse('danger', 'Erreur: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
bulkDeleteBtn.disabled = false;
|
||||||
|
bulkDeleteBtn.textContent = oldText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize button state
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+41
-132
@@ -39,9 +39,8 @@
|
|||||||
|
|
||||||
let categories = [];
|
let categories = [];
|
||||||
let myPicks = {};
|
let myPicks = {};
|
||||||
let savedPicks = {}; // Track saved state to detect changes
|
|
||||||
let currentYear = null;
|
let currentYear = null;
|
||||||
let hasUnsavedChanges = false;
|
let isSaving = false; // Prevent multiple simultaneous saves
|
||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
@@ -133,20 +132,17 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.pick && data.pick.picks) {
|
if (data.pick && data.pick.picks) {
|
||||||
myPicks = {};
|
myPicks = {};
|
||||||
savedPicks = {}; // Reset saved state
|
// Create a map of existing category numbers
|
||||||
|
const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber));
|
||||||
|
|
||||||
|
// Only load picks for categories that still exist
|
||||||
data.pick.picks.forEach(p => {
|
data.pick.picks.forEach(p => {
|
||||||
myPicks[p.categoryNumber] = p.selectedNominee;
|
if (existingCategoryNumbers.has(p.categoryNumber)) {
|
||||||
savedPicks[p.categoryNumber] = p.selectedNominee; // Store saved state
|
myPicks[p.categoryNumber] = p.selectedNominee;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
hasUnsavedChanges = false;
|
|
||||||
renderCategories();
|
renderCategories();
|
||||||
updateProgress();
|
updateProgress();
|
||||||
updateUnsavedChangesIndicator();
|
|
||||||
} else {
|
|
||||||
// No picks saved yet
|
|
||||||
savedPicks = {};
|
|
||||||
hasUnsavedChanges = false;
|
|
||||||
updateUnsavedChangesIndicator();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading picks:', err);
|
console.error('Error loading picks:', err);
|
||||||
@@ -227,71 +223,41 @@
|
|||||||
|
|
||||||
// Re-render to update the display
|
// Re-render to update the display
|
||||||
renderCategories();
|
renderCategories();
|
||||||
checkForUnsavedChanges();
|
|
||||||
|
// Auto-save after selection change
|
||||||
|
autoSavePicks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkForUnsavedChanges() {
|
async function autoSavePicks() {
|
||||||
// Compare current picks with saved picks
|
// Clear any existing timeout to reset the debounce
|
||||||
const currentKeys = Object.keys(myPicks).sort();
|
if (autoSavePicks.timeout) {
|
||||||
const savedKeys = Object.keys(savedPicks).sort();
|
clearTimeout(autoSavePicks.timeout);
|
||||||
|
|
||||||
// Check if counts differ
|
|
||||||
if (currentKeys.length !== savedKeys.length) {
|
|
||||||
hasUnsavedChanges = true;
|
|
||||||
updateUnsavedChangesIndicator();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any values differ or keys are different
|
// Debounce: wait for user to stop clicking before saving
|
||||||
const allKeys = new Set([...currentKeys, ...savedKeys]);
|
autoSavePicks.timeout = setTimeout(async () => {
|
||||||
for (const key of allKeys) {
|
// Skip if already saving (will retry after current save completes)
|
||||||
if (myPicks[key] !== savedPicks[key]) {
|
if (isSaving) {
|
||||||
hasUnsavedChanges = true;
|
// Retry after a short delay
|
||||||
updateUnsavedChangesIndicator();
|
setTimeout(() => autoSavePicks(), 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No changes detected
|
isSaving = true;
|
||||||
hasUnsavedChanges = false;
|
try {
|
||||||
updateUnsavedChangesIndicator();
|
await submitPicks(true); // Pass true for silent mode (no alert)
|
||||||
}
|
} catch (err) {
|
||||||
|
console.error('Auto-save failed:', err);
|
||||||
function updateUnsavedChangesIndicator() {
|
// Show error alert only on failure
|
||||||
const submitBtn = document.getElementById('submit-picks');
|
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
|
||||||
const banner = document.getElementById('unsaved-changes-banner');
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
// Enable button and change style
|
|
||||||
if (submitBtn) {
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.classList.add('btn-warning');
|
|
||||||
submitBtn.classList.remove('btn-primary');
|
|
||||||
}
|
}
|
||||||
// Show banner
|
}, 250); // Reduced to 250ms for better responsiveness
|
||||||
if (banner) {
|
|
||||||
banner.classList.remove('d-none');
|
|
||||||
if (!banner.classList.contains('show')) {
|
|
||||||
banner.classList.add('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Disable button and change style
|
|
||||||
if (submitBtn) {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.classList.remove('btn-warning');
|
|
||||||
submitBtn.classList.add('btn-primary');
|
|
||||||
}
|
|
||||||
// Hide banner
|
|
||||||
if (banner) {
|
|
||||||
banner.classList.add('d-none');
|
|
||||||
banner.classList.remove('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
@@ -300,18 +266,20 @@
|
|||||||
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
|
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitPicks() {
|
async function submitPicks(silent = false) {
|
||||||
const picks = Object.keys(myPicks).map(catNum => ({
|
const picks = Object.keys(myPicks).map(catNum => ({
|
||||||
categoryNumber: parseInt(catNum),
|
categoryNumber: parseInt(catNum),
|
||||||
selectedNominee: myPicks[catNum]
|
selectedNominee: myPicks[catNum]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (picks.length === 0) {
|
// Allow empty picks to be saved (to clear all selections)
|
||||||
showAlert('Veuillez sélectionner au moins un choix', 'warning');
|
// Only show warning in non-silent mode if user explicitly tries to submit with no picks
|
||||||
return;
|
if (picks.length === 0 && !silent) {
|
||||||
|
// Don't block - allow saving empty picks to clear selections
|
||||||
}
|
}
|
||||||
|
|
||||||
if (picks.length < categories.length) {
|
// Skip confirmation dialog in silent mode (auto-save)
|
||||||
|
if (!silent && 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?`)) {
|
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -336,11 +304,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Update saved state after successful save
|
if (!silent) {
|
||||||
savedPicks = { ...myPicks };
|
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
||||||
hasUnsavedChanges = false;
|
}
|
||||||
updateUnsavedChangesIndicator();
|
|
||||||
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error submitting picks:', err);
|
console.error('Error submitting picks:', err);
|
||||||
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
|
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
|
||||||
@@ -348,7 +314,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
|
|
||||||
document.getElementById('load-my-picks')?.addEventListener('click', () => {
|
document.getElementById('load-my-picks')?.addEventListener('click', () => {
|
||||||
loadMyPicks();
|
loadMyPicks();
|
||||||
showAlert('Choix rechargés', 'info');
|
showAlert('Choix rechargés', 'info');
|
||||||
@@ -599,7 +564,6 @@
|
|||||||
<th>Utilisateur</th>
|
<th>Utilisateur</th>
|
||||||
<th class="text-center" style="width: 80px;">Score</th>
|
<th class="text-center" style="width: 80px;">Score</th>
|
||||||
<th class="text-center" style="width: 70px;">Total</th>
|
<th class="text-center" style="width: 70px;">Total</th>
|
||||||
<th class="text-center" style="width: 70px;">%</th>
|
|
||||||
<th class="text-center" style="width: 100px;">Actions</th>
|
<th class="text-center" style="width: 100px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -607,7 +571,6 @@
|
|||||||
${validScores.map((score, index) => {
|
${validScores.map((score, index) => {
|
||||||
// Use the pre-calculated actualScore (always use it, even if 0)
|
// Use the pre-calculated actualScore (always use it, even if 0)
|
||||||
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
|
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
|
||||||
const percentage = totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0;
|
|
||||||
const rank = index + 1;
|
const rank = index + 1;
|
||||||
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
||||||
return `
|
return `
|
||||||
@@ -616,7 +579,6 @@
|
|||||||
<td>${score.userName}</td>
|
<td>${score.userName}</td>
|
||||||
<td class="text-center"><strong class="text-success">${actualScore}</strong></td>
|
<td class="text-center"><strong class="text-success">${actualScore}</strong></td>
|
||||||
<td class="text-center text-muted small">${totalCategories}</td>
|
<td class="text-center text-muted small">${totalCategories}</td>
|
||||||
<td class="text-center"><strong>${percentage}%</strong></td>
|
|
||||||
<td class="text-center">
|
<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}">
|
<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}">
|
||||||
Détails
|
Détails
|
||||||
@@ -746,7 +708,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Event listeners for winners tab
|
// Event listeners for winners tab
|
||||||
document.getElementById('calculate-scores')?.addEventListener('click', calculateScores);
|
|
||||||
document.getElementById('refresh-winners')?.addEventListener('click', () => {
|
document.getElementById('refresh-winners')?.addEventListener('click', () => {
|
||||||
fetchWinnersCategories();
|
fetchWinnersCategories();
|
||||||
showAlert('Données rafraîchies', 'info');
|
showAlert('Données rafraîchies', 'info');
|
||||||
@@ -755,14 +716,6 @@
|
|||||||
// Load scores when scores tab is shown
|
// Load scores when scores tab is shown
|
||||||
const scoresTab = document.getElementById('tab-scores');
|
const scoresTab = document.getElementById('tab-scores');
|
||||||
if (scoresTab) {
|
if (scoresTab) {
|
||||||
scoresTab.addEventListener('show.bs.tab', (e) => {
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) {
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
scoresTab.addEventListener('shown.bs.tab', async () => {
|
scoresTab.addEventListener('shown.bs.tab', async () => {
|
||||||
await fetchScores();
|
await fetchScores();
|
||||||
});
|
});
|
||||||
@@ -771,14 +724,6 @@
|
|||||||
// Load winners when admin tab is shown
|
// Load winners when admin tab is shown
|
||||||
const winnersTab = document.getElementById('tab-winners');
|
const winnersTab = document.getElementById('tab-winners');
|
||||||
if (winnersTab) {
|
if (winnersTab) {
|
||||||
winnersTab.addEventListener('show.bs.tab', (e) => {
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
if (!confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir changer d\'onglet? Vos modifications seront perdues.')) {
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
winnersTab.addEventListener('shown.bs.tab', async () => {
|
winnersTab.addEventListener('shown.bs.tab', async () => {
|
||||||
await fetchWinnersCategories();
|
await fetchWinnersCategories();
|
||||||
// Auto-refresh scores when entering the winners tab (silent mode)
|
// Auto-refresh scores when entering the winners tab (silent mode)
|
||||||
@@ -792,44 +737,8 @@
|
|||||||
showAlert('Scores rafraîchis', 'info');
|
showAlert('Scores rafraîchis', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Warn before leaving page with unsaved changes
|
|
||||||
window.addEventListener('beforeunload', (e) => {
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = 'Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?';
|
|
||||||
return e.returnValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Intercept navigation links to warn about unsaved changes
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
// Check if clicking on a navigation link
|
|
||||||
const link = e.target.closest('a[href]');
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
// Skip if it's the same page or a hash link
|
|
||||||
const href = link.getAttribute('href');
|
|
||||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
||||||
|
|
||||||
// Skip if it's the submit button or other action buttons
|
|
||||||
if (link.closest('#pane-my-picks') || link.id === 'submit-picks') return;
|
|
||||||
|
|
||||||
// Check for unsaved changes
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
e.preventDefault();
|
|
||||||
const confirmed = confirm('Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page? Vos modifications seront perdues.');
|
|
||||||
if (confirmed) {
|
|
||||||
hasUnsavedChanges = false; // Allow navigation
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
(async () => {
|
(async () => {
|
||||||
// Initialize button state (disabled by default)
|
|
||||||
updateUnsavedChangesIndicator();
|
|
||||||
|
|
||||||
currentYear = await fetchActiveYear();
|
currentYear = await fetchActiveYear();
|
||||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||||
await fetchCategories();
|
await fetchCategories();
|
||||||
|
|||||||
@@ -668,16 +668,23 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
|
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by total points (descending), then by watched ratio
|
// Sort by total points (descending)
|
||||||
stats.sort((a, b) => {
|
stats.sort((a, b) => {
|
||||||
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
|
return (b.totalPoints || 0) - (a.totalPoints || 0);
|
||||||
if (pointsDiff !== 0) return pointsDiff;
|
|
||||||
return parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear previous rows to prevent duplication
|
// Clear previous rows to prevent duplication
|
||||||
userTableBody.innerHTML = '';
|
userTableBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Get total movies count (same for all users in the same year)
|
||||||
|
const totalMovies = stats.length > 0 ? (stats[0].totalMoviesCount || 0) : 0;
|
||||||
|
|
||||||
|
// Update Films column header with total
|
||||||
|
const filmsHeader = document.getElementById('films-header');
|
||||||
|
if (filmsHeader && totalMovies > 0) {
|
||||||
|
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
stats.forEach((userStat, index) => {
|
stats.forEach((userStat, index) => {
|
||||||
const userRow = document.createElement('tr');
|
const userRow = document.createElement('tr');
|
||||||
const rank = index + 1;
|
const rank = index + 1;
|
||||||
@@ -712,15 +719,11 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
pointsTd.className = 'text-center';
|
pointsTd.className = 'text-center';
|
||||||
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
|
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(rankTd);
|
||||||
userRow.appendChild(nameTd);
|
userRow.appendChild(nameTd);
|
||||||
userRow.appendChild(countTd);
|
userRow.appendChild(countTd);
|
||||||
userRow.appendChild(picksTd);
|
userRow.appendChild(picksTd);
|
||||||
userRow.appendChild(pointsTd);
|
userRow.appendChild(pointsTd);
|
||||||
userRow.appendChild(ratioTd);
|
|
||||||
userTableBody.appendChild(userRow);
|
userTableBody.appendChild(userRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -168,15 +168,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
|
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
|
||||||
<button type="button" id="submit-picks" class="btn btn-sm btn-primary">Enregistrer</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="unsaved-changes-banner" class="alert alert-warning alert-dismissible fade d-none" role="alert">
|
|
||||||
<strong>⚠️ Modifications en attente:</strong> Vous avez des modifications non enregistrées. N'oubliez pas d'enregistrer vos choix avant de quitter cette page.
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="categories-container">
|
<div id="categories-container">
|
||||||
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
|
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +200,6 @@
|
|||||||
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> gagnants marqués</span>
|
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> gagnants marqués</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" id="calculate-scores" class="btn btn-sm btn-success">Calculer</button>
|
|
||||||
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
|
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,10 +111,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">#</th>
|
<th scope="col">#</th>
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
<th scope="col">Films</th>
|
<th scope="col" id="films-header">Films</th>
|
||||||
<th scope="col" id="bon-picks-header">Bon picks</th>
|
<th scope="col" id="bon-picks-header">Bon picks</th>
|
||||||
<th scope="col">Points</th>
|
<th scope="col" class="text-center">Points</th>
|
||||||
<th scope="col">Ratio</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="user-table-body">
|
<tbody id="user-table-body">
|
||||||
|
|||||||
+165
-237
@@ -1,26 +1,12 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const multer = require("multer");
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { PDFParse } = require("pdf-parse");
|
|
||||||
const OscarCategory = require("../models/OscarCategory");
|
const OscarCategory = require("../models/OscarCategory");
|
||||||
|
const OscarPick = require("../models/OscarPick");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Configure multer for PDF uploads
|
|
||||||
const upload = multer({
|
|
||||||
dest: path.join(__dirname, '../uploads/'),
|
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
if (file.mimetype === 'application/pdf') {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error('Only PDF files are allowed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Middleware to verify token
|
// Middleware to verify token
|
||||||
function verifyToken(req, res, next) {
|
function verifyToken(req, res, next) {
|
||||||
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
|
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
|
||||||
@@ -59,8 +45,10 @@ function cleanNomineeName(line) {
|
|||||||
|
|
||||||
// Helper function to check if a line is a valid nominee
|
// Helper function to check if a line is a valid nominee
|
||||||
function isValidNomineeLine(line, skipPatterns) {
|
function isValidNomineeLine(line, skipPatterns) {
|
||||||
// Skip if it looks like a category header
|
// Skip if it looks like a category header (number first or name first with number)
|
||||||
if (line.match(/^(.+?)[\t\s]+\d+$/) || line.match(/^\d+\./)) {
|
if (line.match(/^(\d+)[\t\s]+(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)/i) ||
|
||||||
|
line.match(/^(.+?)[\t\s]+\d+$/) ||
|
||||||
|
line.match(/^\d+\./)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +62,11 @@ function isValidNomineeLine(line, skipPatterns) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip lines that are just numbers or dashes
|
||||||
|
if (nomineeName.match(/^-?\s*\d+\s*-?$/) || nomineeName.match(/^-+\s*$/)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const upperLine = nomineeName.toUpperCase();
|
const upperLine = nomineeName.toUpperCase();
|
||||||
// Skip if it looks like a header (but allow category names that might appear in nominees with commas)
|
// Skip if it looks like a header (but allow category names that might appear in nominees with commas)
|
||||||
const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) ||
|
const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) ||
|
||||||
@@ -83,218 +76,7 @@ function isValidNomineeLine(line, skipPatterns) {
|
|||||||
return !isHeader;
|
return !isHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse PDF text and extract categories/nominees
|
|
||||||
async function parsePDFText(pdfText) {
|
|
||||||
const categories = [];
|
|
||||||
const lines = pdfText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
|
||||||
|
|
||||||
// First pass: identify all category headers and their positions
|
|
||||||
const categoryHeaders = [];
|
|
||||||
const skipPatterns = /^(COVERS|OSCARS|PARTY|PROPS|SHEET|YOUR|NAME|SCORE|____|Your Name|Your Score)/i;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Skip header/metadata lines
|
|
||||||
if (skipPatterns.test(line)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match category in format: "Category Name\tNumber" or "Category Name Number"
|
|
||||||
const categoryMatch = line.match(/^(.+?)[\t\s]+(\d+)$/);
|
|
||||||
if (categoryMatch) {
|
|
||||||
const categoryName = categoryMatch[1].trim().replace(/\*\*/g, '').trim();
|
|
||||||
const categoryNumber = parseInt(categoryMatch[2]);
|
|
||||||
|
|
||||||
// Skip invalid entries
|
|
||||||
if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out false positives - must contain common Oscar category keywords
|
|
||||||
const upperName = categoryName.toUpperCase();
|
|
||||||
const isOscarCategory = upperName.includes('BEST') ||
|
|
||||||
upperName.includes('DIRECTOR') ||
|
|
||||||
upperName.includes('ACTOR') ||
|
|
||||||
upperName.includes('ACTRESS') ||
|
|
||||||
upperName.includes('PICTURE') ||
|
|
||||||
upperName.includes('SCREENPLAY') ||
|
|
||||||
upperName.includes('EDITING') ||
|
|
||||||
upperName.includes('CINEMATOGRAPHY') ||
|
|
||||||
upperName.includes('COSTUME') ||
|
|
||||||
upperName.includes('SOUND') ||
|
|
||||||
upperName.includes('VISUAL') ||
|
|
||||||
upperName.includes('EFFECTS') ||
|
|
||||||
upperName.includes('ORIGINAL') ||
|
|
||||||
upperName.includes('ADAPTED') ||
|
|
||||||
upperName.includes('DOCUMENTARY') ||
|
|
||||||
upperName.includes('ANIMATED') ||
|
|
||||||
upperName.includes('INTERNATIONAL') ||
|
|
||||||
upperName.includes('PRODUCTION') ||
|
|
||||||
upperName.includes('MAKEUP') ||
|
|
||||||
upperName.includes('HAIRSTYLING') ||
|
|
||||||
upperName.includes('LIVE-ACTION') ||
|
|
||||||
upperName.includes('SHORT');
|
|
||||||
|
|
||||||
// Also allow "Makeup and Hairstyling" format
|
|
||||||
if (!isOscarCategory && !upperName.match(/^(MAKEUP|PRODUCTION|SOUND)$/)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryHeaders.push({
|
|
||||||
index: i,
|
|
||||||
categoryNumber: categoryNumber,
|
|
||||||
categoryName: categoryName,
|
|
||||||
rawText: line
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also try format: "Number. Category Name" (fallback)
|
|
||||||
const altCategoryMatch = line.match(/^(\d+)\.\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*-)?$/);
|
|
||||||
if (altCategoryMatch) {
|
|
||||||
const categoryNumber = parseInt(altCategoryMatch[1]);
|
|
||||||
let categoryName = altCategoryMatch[2].trim().replace(/\*\*/g, '').replace(/^-\s*/, '').trim();
|
|
||||||
|
|
||||||
if (categoryName.length < 3 || categoryNumber < 1 || categoryNumber > 50) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryHeaders.push({
|
|
||||||
index: i,
|
|
||||||
categoryNumber: categoryNumber,
|
|
||||||
categoryName: categoryName,
|
|
||||||
rawText: line
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort category headers by their position in the document
|
|
||||||
categoryHeaders.sort((a, b) => a.index - b.index);
|
|
||||||
|
|
||||||
// Second pass: collect nominees for each category
|
|
||||||
// In the PDF, nominees can appear BEFORE their category header
|
|
||||||
for (let h = 0; h < categoryHeaders.length; h++) {
|
|
||||||
const header = categoryHeaders[h];
|
|
||||||
const prevHeaderIndex = h > 0 ? categoryHeaders[h - 1].index : -1;
|
|
||||||
const nextHeaderIndex = h < categoryHeaders.length - 1 ? categoryHeaders[h + 1].index : lines.length;
|
|
||||||
|
|
||||||
const nominees = [];
|
|
||||||
|
|
||||||
// Collect nominees that appear AFTER this category header (until next category)
|
|
||||||
// Focus on nominees after the header for better accuracy
|
|
||||||
for (let i = header.index + 1; i < nextHeaderIndex; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (isValidNomineeLine(line, skipPatterns)) {
|
|
||||||
const nomineeName = cleanNomineeName(line);
|
|
||||||
// Skip obviously wrong entries like "- 1 of 1 --"
|
|
||||||
if (nomineeName && !nomineeName.match(/^-?\s*\d+\s*of\s*\d+\s*-?$/)) {
|
|
||||||
nominees.push({ name: nomineeName, isWinner: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove duplicates
|
|
||||||
const uniqueNominees = [];
|
|
||||||
const seen = new Set();
|
|
||||||
for (const nom of nominees) {
|
|
||||||
const key = nom.name.toLowerCase().trim();
|
|
||||||
if (key && !seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
uniqueNominees.push(nom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add category if it has nominees
|
|
||||||
if (uniqueNominees.length > 0) {
|
|
||||||
categories.push({
|
|
||||||
categoryNumber: header.categoryNumber,
|
|
||||||
categoryName: header.categoryName,
|
|
||||||
nominees: uniqueNominees,
|
|
||||||
rawText: header.rawText
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort categories by category number
|
|
||||||
categories.sort((a, b) => a.categoryNumber - b.categoryNumber);
|
|
||||||
|
|
||||||
return categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import categories from PDF (admin only)
|
|
||||||
router.post("/import-pdf", verifyToken, upload.single('pdf'), async (req, res) => {
|
|
||||||
try {
|
|
||||||
if (!isAdmin(req.user)) {
|
|
||||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({ message: "No PDF file uploaded" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = parseOscarYear(req.body.year) || new Date().getFullYear();
|
|
||||||
const pdfPath = req.file.path;
|
|
||||||
|
|
||||||
// Parse PDF
|
|
||||||
let pdfText;
|
|
||||||
try {
|
|
||||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
|
||||||
const parser = new PDFParse({ data: pdfBuffer });
|
|
||||||
const textResult = await parser.getText();
|
|
||||||
pdfText = textResult.text;
|
|
||||||
|
|
||||||
// Cleanup parser
|
|
||||||
await parser.destroy();
|
|
||||||
} catch (err) {
|
|
||||||
// Cleanup file
|
|
||||||
fs.unlinkSync(pdfPath);
|
|
||||||
return res.status(400).json({ message: `Error parsing PDF: ${err.message}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup uploaded file
|
|
||||||
fs.unlinkSync(pdfPath);
|
|
||||||
|
|
||||||
// Parse categories from text
|
|
||||||
const parsedCategories = await parsePDFText(pdfText);
|
|
||||||
|
|
||||||
if (parsedCategories.length === 0) {
|
|
||||||
return res.status(400).json({ message: "No categories found in PDF. Please check the format." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save categories to database
|
|
||||||
const savedCategories = [];
|
|
||||||
for (const cat of parsedCategories) {
|
|
||||||
const category = await OscarCategory.findOneAndUpdate(
|
|
||||||
{ year, categoryNumber: cat.categoryNumber },
|
|
||||||
{
|
|
||||||
year,
|
|
||||||
categoryNumber: cat.categoryNumber,
|
|
||||||
categoryName: cat.categoryName,
|
|
||||||
nominees: cat.nominees,
|
|
||||||
rawText: cat.rawText
|
|
||||||
},
|
|
||||||
{ upsert: true, new: true }
|
|
||||||
);
|
|
||||||
savedCategories.push(category);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: `Successfully imported ${savedCategories.length} categories`,
|
|
||||||
year,
|
|
||||||
categories: savedCategories
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error importing PDF:", err);
|
|
||||||
// Cleanup file if it exists
|
|
||||||
if (req.file && req.file.path) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(req.file.path);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Manually create/update categories (admin only)
|
// Manually create/update categories (admin only)
|
||||||
router.post("/create", verifyToken, async (req, res) => {
|
router.post("/create", verifyToken, async (req, res) => {
|
||||||
@@ -352,6 +134,26 @@ router.get("/list", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get a single category by ID (admin only) - must come after /list route
|
||||||
|
router.get("/:categoryId", verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req.user)) {
|
||||||
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await OscarCategory.findById(req.params.categoryId).lean();
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return res.status(404).json({ message: "Category not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ category });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching category:", err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update a category (admin only)
|
// Update a category (admin only)
|
||||||
router.put("/:categoryId", verifyToken, async (req, res) => {
|
router.put("/:categoryId", verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -359,23 +161,96 @@ router.put("/:categoryId", verifyToken, async (req, res) => {
|
|||||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { categoryName, nominees } = req.body;
|
const { categoryNumber, categoryName, nominees } = req.body;
|
||||||
|
|
||||||
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
|
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
|
||||||
return res.status(400).json({ message: "categoryName and nominees array are required" });
|
return res.status(400).json({ message: "categoryName and nominees array are required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (categoryNumber !== undefined && categoryNumber !== null) {
|
||||||
|
const categoryNum = parseInt(categoryNumber);
|
||||||
|
if (!Number.isInteger(categoryNum) || categoryNum < 1 || categoryNum > 50) {
|
||||||
|
return res.status(400).json({ message: "categoryNumber must be between 1 and 50" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nomineesArray = nominees.map(n => ({
|
const nomineesArray = nominees.map(n => ({
|
||||||
name: typeof n === 'string' ? n : n.name,
|
name: typeof n === 'string' ? n : n.name,
|
||||||
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
|
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Get the current category to check year and handle categoryNumber conflicts
|
||||||
|
const currentCategory = await OscarCategory.findById(req.params.categoryId);
|
||||||
|
if (!currentCategory) {
|
||||||
|
return res.status(404).json({ message: "Category not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = currentCategory.year;
|
||||||
|
const updateData = {
|
||||||
|
categoryName,
|
||||||
|
nominees: nomineesArray
|
||||||
|
};
|
||||||
|
|
||||||
|
// If categoryNumber is being changed, check for conflicts and update existing picks
|
||||||
|
if (categoryNumber !== undefined && categoryNumber !== null) {
|
||||||
|
const newCategoryNum = parseInt(categoryNumber);
|
||||||
|
if (newCategoryNum !== currentCategory.categoryNumber) {
|
||||||
|
const oldCategoryNum = currentCategory.categoryNumber;
|
||||||
|
|
||||||
|
// Check if another category with this number already exists for this year
|
||||||
|
const existingCategory = await OscarCategory.findOne({
|
||||||
|
year,
|
||||||
|
categoryNumber: newCategoryNum,
|
||||||
|
_id: { $ne: req.params.categoryId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: `Category number ${newCategoryNum} already exists for year ${year}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all existing picks that reference the old category number
|
||||||
|
// Match by old categoryNumber and old categoryName to ensure we're updating the right picks
|
||||||
|
const picksToUpdate = await OscarPick.find({ year });
|
||||||
|
let updatedPicksCount = 0;
|
||||||
|
|
||||||
|
for (const pick of picksToUpdate) {
|
||||||
|
if (pick.picks && Array.isArray(pick.picks)) {
|
||||||
|
let needsUpdate = false;
|
||||||
|
const updatedPicks = pick.picks.map(p => {
|
||||||
|
// Match by both old categoryNumber and old categoryName to be safe
|
||||||
|
if (p.categoryNumber === oldCategoryNum && p.categoryName === currentCategory.categoryName) {
|
||||||
|
needsUpdate = true;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
categoryNumber: newCategoryNum,
|
||||||
|
categoryName: categoryName // Update categoryName too if it changed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
pick.picks = updatedPicks;
|
||||||
|
await pick.save();
|
||||||
|
updatedPicksCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.categoryNumber = newCategoryNum;
|
||||||
|
|
||||||
|
// Log the update (optional, for debugging)
|
||||||
|
if (updatedPicksCount > 0) {
|
||||||
|
console.log(`Updated ${updatedPicksCount} pick(s) when changing category number from ${oldCategoryNum} to ${newCategoryNum}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const category = await OscarCategory.findByIdAndUpdate(
|
const category = await OscarCategory.findByIdAndUpdate(
|
||||||
req.params.categoryId,
|
req.params.categoryId,
|
||||||
{
|
updateData,
|
||||||
categoryName,
|
|
||||||
nominees: nomineesArray
|
|
||||||
},
|
|
||||||
{ new: true }
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -398,15 +273,23 @@ router.put("/:categoryId/winner", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { winnerName } = req.body;
|
const { winnerName } = req.body;
|
||||||
if (!winnerName) {
|
const isClearing = !winnerName || winnerName === '' || winnerName === null;
|
||||||
return res.status(400).json({ message: "winnerName is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const category = await OscarCategory.findById(req.params.categoryId);
|
const category = await OscarCategory.findById(req.params.categoryId);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return res.status(404).json({ message: "Category not found" });
|
return res.status(404).json({ message: "Category not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isClearing) {
|
||||||
|
// Clear all winners (set all nominees to not winners)
|
||||||
|
category.nominees = category.nominees.map(n => ({
|
||||||
|
...n.toObject(),
|
||||||
|
isWinner: false
|
||||||
|
}));
|
||||||
|
await category.save();
|
||||||
|
return res.json({ message: "Winner cleared successfully", category });
|
||||||
|
}
|
||||||
|
|
||||||
// Mark all nominees as not winners, then mark the selected one as winner
|
// Mark all nominees as not winners, then mark the selected one as winner
|
||||||
category.nominees = category.nominees.map(n => ({
|
category.nominees = category.nominees.map(n => ({
|
||||||
...n.toObject(),
|
...n.toObject(),
|
||||||
@@ -422,6 +305,43 @@ router.put("/:categoryId/winner", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to clean up picks for deleted categories
|
||||||
|
async function cleanupPicksForDeletedCategories(year) {
|
||||||
|
try {
|
||||||
|
const categories = await OscarCategory.find({ year }).lean();
|
||||||
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
|
|
||||||
|
const allPicks = await OscarPick.find({ year });
|
||||||
|
let totalCleaned = 0;
|
||||||
|
|
||||||
|
for (const pick of allPicks) {
|
||||||
|
if (pick.picks && Array.isArray(pick.picks)) {
|
||||||
|
const originalLength = pick.picks.length;
|
||||||
|
pick.picks = pick.picks.filter(p => {
|
||||||
|
const category = categoryMap.get(p.categoryNumber);
|
||||||
|
if (!category) return false; // Category was deleted
|
||||||
|
// Also check if nominee still exists in category
|
||||||
|
return category.nominees.some(n => n.name === p.selectedNominee);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pick.picks.length !== originalLength) {
|
||||||
|
await pick.save();
|
||||||
|
totalCleaned += (originalLength - pick.picks.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCleaned > 0) {
|
||||||
|
console.log(`Cleaned up ${totalCleaned} pick(s) for deleted categories in year ${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCleaned;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error cleaning up picks:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete a category (admin only)
|
// Delete a category (admin only)
|
||||||
router.delete("/:categoryId", verifyToken, async (req, res) => {
|
router.delete("/:categoryId", verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -429,11 +349,19 @@ router.delete("/:categoryId", verifyToken, async (req, res) => {
|
|||||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await OscarCategory.findByIdAndDelete(req.params.categoryId);
|
const category = await OscarCategory.findById(req.params.categoryId);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return res.status(404).json({ message: "Category not found" });
|
return res.status(404).json({ message: "Category not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const year = category.year;
|
||||||
|
|
||||||
|
// Delete the category
|
||||||
|
await OscarCategory.findByIdAndDelete(req.params.categoryId);
|
||||||
|
|
||||||
|
// Clean up picks for deleted categories
|
||||||
|
await cleanupPicksForDeletedCategories(year);
|
||||||
|
|
||||||
res.json({ message: "Category deleted successfully" });
|
res.json({ message: "Category deleted successfully" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting category:", err);
|
console.error("Error deleting category:", err);
|
||||||
|
|||||||
+95
-30
@@ -68,8 +68,8 @@ router.post("/submit", verifyToken, async (req, res) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
|
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
|
||||||
|
|
||||||
if (!Array.isArray(picks) || picks.length === 0) {
|
if (!Array.isArray(picks)) {
|
||||||
return res.status(400).json({ message: "Picks array is required" });
|
return res.status(400).json({ message: "Picks must be an array" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify categories exist for this year
|
// Verify categories exist for this year
|
||||||
@@ -78,31 +78,38 @@ router.post("/submit", verifyToken, async (req, res) => {
|
|||||||
return res.status(400).json({ message: "No categories found for this year. Please import categories first." });
|
return res.status(400).json({ message: "No categories found for this year. Please import categories first." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate picks
|
// Filter and validate picks - remove picks for deleted categories
|
||||||
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
for (const pick of picks) {
|
const validPicks = [];
|
||||||
if (!pick.categoryNumber || !pick.selectedNominee) {
|
|
||||||
return res.status(400).json({ message: "Each pick must have categoryNumber and selectedNominee" });
|
if (picks.length > 0) {
|
||||||
}
|
for (const pick of picks) {
|
||||||
const category = categoryMap.get(pick.categoryNumber);
|
if (!pick.categoryNumber || !pick.selectedNominee) {
|
||||||
if (!category) {
|
continue; // Skip invalid picks
|
||||||
return res.status(400).json({ message: `Category ${pick.categoryNumber} not found` });
|
}
|
||||||
}
|
const category = categoryMap.get(pick.categoryNumber);
|
||||||
const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
|
if (!category) {
|
||||||
if (!nomineeExists) {
|
// Category was deleted - skip this pick
|
||||||
return res.status(400).json({ message: `Nominee "${pick.selectedNominee}" not found in category ${pick.categoryNumber}` });
|
continue;
|
||||||
|
}
|
||||||
|
const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
|
||||||
|
if (!nomineeExists) {
|
||||||
|
// Nominee doesn't exist in this category - skip this pick
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validPicks.push({
|
||||||
|
categoryNumber: pick.categoryNumber,
|
||||||
|
categoryName: category.categoryName,
|
||||||
|
selectedNominee: pick.selectedNominee
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update picks
|
// Create or update picks (only with valid picks)
|
||||||
const pickData = {
|
const pickData = {
|
||||||
userId,
|
userId,
|
||||||
year: yearNum,
|
year: yearNum,
|
||||||
picks: picks.map(p => ({
|
picks: validPicks
|
||||||
categoryNumber: p.categoryNumber,
|
|
||||||
categoryName: categoryMap.get(p.categoryNumber).categoryName,
|
|
||||||
selectedNominee: p.selectedNominee
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingPick = await OscarPick.findOne({ userId, year: yearNum });
|
const existingPick = await OscarPick.findOne({ userId, year: yearNum });
|
||||||
@@ -128,15 +135,32 @@ router.get("/my-picks", verifyToken, async (req, res) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||||
|
|
||||||
const pick = await OscarPick.findOne({ userId, year })
|
const pick = await OscarPick.findOne({ userId, year });
|
||||||
.populate('userId', 'name email')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
if (!pick) {
|
if (!pick) {
|
||||||
return res.json({ pick: null, year });
|
return res.json({ pick: null, year });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ pick, year });
|
// Clean up picks for deleted categories
|
||||||
|
const categories = await OscarCategory.find({ year }).lean();
|
||||||
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
|
|
||||||
|
if (pick.picks && Array.isArray(pick.picks)) {
|
||||||
|
const originalLength = pick.picks.length;
|
||||||
|
pick.picks = pick.picks.filter(p => {
|
||||||
|
const category = categoryMap.get(p.categoryNumber);
|
||||||
|
if (!category) return false; // Category was deleted
|
||||||
|
// Also check if nominee still exists in category
|
||||||
|
return category.nominees.some(n => n.name === p.selectedNominee);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If picks were cleaned up, save the updated pick
|
||||||
|
if (pick.picks.length !== originalLength) {
|
||||||
|
await pick.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ pick: pick.toObject(), year });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching user picks:", err);
|
console.error("Error fetching user picks:", err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -199,13 +223,23 @@ router.post("/calculate-scores", verifyToken, async (req, res) => {
|
|||||||
.populate('userId', 'name email')
|
.populate('userId', 'name email')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
|
// Create a map of all categories for reference
|
||||||
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
|
|
||||||
// Calculate scores
|
// Calculate scores
|
||||||
const scores = [];
|
const scores = [];
|
||||||
for (const pick of allPicks) {
|
for (const pick of allPicks) {
|
||||||
let correctCount = 0;
|
let correctCount = 0;
|
||||||
const pickDetails = [];
|
const pickDetails = [];
|
||||||
|
|
||||||
|
// Only process picks for categories that still exist
|
||||||
pick.picks.forEach(p => {
|
pick.picks.forEach(p => {
|
||||||
|
const category = categoryMap.get(p.categoryNumber);
|
||||||
|
if (!category) {
|
||||||
|
// Category was deleted - skip this pick
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const correctWinner = categoryWinners.get(p.categoryNumber);
|
const correctWinner = categoryWinners.get(p.categoryNumber);
|
||||||
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
||||||
if (isCorrect) correctCount++;
|
if (isCorrect) correctCount++;
|
||||||
@@ -219,8 +253,12 @@ router.post("/calculate-scores", verifyToken, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the pick with score
|
// Update the pick with score and clean up deleted category picks
|
||||||
await OscarPick.findByIdAndUpdate(pick._id, { score: correctCount });
|
const validPicks = pick.picks.filter(p => categoryMap.has(p.categoryNumber));
|
||||||
|
await OscarPick.findByIdAndUpdate(pick._id, {
|
||||||
|
score: correctCount,
|
||||||
|
picks: validPicks
|
||||||
|
});
|
||||||
|
|
||||||
scores.push({
|
scores.push({
|
||||||
userId: pick.userId?._id || pick.userId,
|
userId: pick.userId?._id || pick.userId,
|
||||||
@@ -253,12 +291,33 @@ router.get("/scores", verifyToken, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||||
|
|
||||||
|
// Clean up picks for deleted categories before fetching scores
|
||||||
|
const categories = await OscarCategory.find({ year }).lean();
|
||||||
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
|
|
||||||
|
const picksToClean = await OscarPick.find({ year });
|
||||||
|
for (const pick of picksToClean) {
|
||||||
|
if (pick.picks && Array.isArray(pick.picks)) {
|
||||||
|
const originalLength = pick.picks.length;
|
||||||
|
pick.picks = pick.picks.filter(p => {
|
||||||
|
const category = categoryMap.get(p.categoryNumber);
|
||||||
|
if (!category) return false; // Category was deleted
|
||||||
|
// Also check if nominee still exists in category
|
||||||
|
return category.nominees.some(n => n.name === p.selectedNominee);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pick.picks.length !== originalLength) {
|
||||||
|
await pick.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const picks = await OscarPick.find({ year })
|
const picks = await OscarPick.find({ year })
|
||||||
.populate('userId', 'name email')
|
.populate('userId', 'name email')
|
||||||
.sort({ score: -1, submittedAt: 1 })
|
.sort({ score: -1, submittedAt: 1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
const categories = await OscarCategory.find({ year }).lean();
|
// Reuse categories already fetched for cleanup
|
||||||
const categoryWinners = new Map();
|
const categoryWinners = new Map();
|
||||||
categories.forEach(cat => {
|
categories.forEach(cat => {
|
||||||
const winner = cat.nominees.find(n => n.isWinner);
|
const winner = cat.nominees.find(n => n.isWinner);
|
||||||
@@ -274,13 +333,19 @@ router.get("/scores", verifyToken, async (req, res) => {
|
|||||||
// Create a map of all categories for reference
|
// Create a map of all categories for reference
|
||||||
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
|
||||||
|
|
||||||
|
// Only process picks for categories that still exist
|
||||||
pick.picks.forEach(p => {
|
pick.picks.forEach(p => {
|
||||||
|
const category = categoryMap.get(p.categoryNumber);
|
||||||
|
if (!category) {
|
||||||
|
// Category was deleted - skip this pick
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const correctWinner = categoryWinners.get(p.categoryNumber);
|
const correctWinner = categoryWinners.get(p.categoryNumber);
|
||||||
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
|
||||||
if (isCorrect) correctCount++;
|
if (isCorrect) correctCount++;
|
||||||
|
|
||||||
const category = categoryMap.get(p.categoryNumber);
|
const allNominees = category.nominees.map(n => n.name);
|
||||||
const allNominees = category ? category.nominees.map(n => n.name) : [];
|
|
||||||
|
|
||||||
pickDetails.push({
|
pickDetails.push({
|
||||||
categoryNumber: p.categoryNumber,
|
categoryNumber: p.categoryNumber,
|
||||||
@@ -296,7 +361,7 @@ router.get("/scores", verifyToken, async (req, res) => {
|
|||||||
userId: pick.userId?._id || pick.userId,
|
userId: pick.userId?._id || pick.userId,
|
||||||
userName: pick.userId?.name || 'Unknown',
|
userName: pick.userId?.name || 'Unknown',
|
||||||
userEmail: pick.userId?.email || '',
|
userEmail: pick.userId?.email || '',
|
||||||
score: pick.score !== null && pick.score !== undefined ? pick.score : correctCount,
|
score: correctCount, // Always use the calculated score from existing categories
|
||||||
totalCategories: categories.length,
|
totalCategories: categories.length,
|
||||||
pickDetails,
|
pickDetails,
|
||||||
submittedAt: pick.submittedAt
|
submittedAt: pick.submittedAt
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 765 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 765 KiB |
Reference in New Issue
Block a user