@@ -1023,11 +972,17 @@
Catégories importées
Gérez les catégories existantes
-
-
- Toutes les années
-
-
Rafraîchir
+
+
+
+ Toutes les années
+
+ Rafraîchir
+ Supprimer
+
+
+ 0 catégorie(s) sélectionnée(s)
+
@@ -1279,7 +1234,7 @@
Numéro de catégorie
-
+
diff --git a/public/js/app_control.js b/public/js/app_control.js
index fd37b69..522deaf 100644
--- a/public/js/app_control.js
+++ b/public/js/app_control.js
@@ -2096,38 +2096,9 @@ window.onload = async function () {
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;
@@ -2761,14 +2732,13 @@ window.onload = async function () {
}
// 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');
+ const alertEl = document.getElementById('categories-manual-alert');
if (!alertEl) return;
alertEl.className = `alert alert-${type}`;
alertEl.textContent = message;
@@ -2778,44 +2748,6 @@ window.onload = async function () {
}, 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();
@@ -2897,6 +2829,9 @@ window.onload = async function () {
`;
+
+ // Setup bulk selection handlers
+ setupBulkCategorySelection();
} catch (err) {
categoriesListContainer.innerHTML = `
Erreur: ${err.message}
`;
}
@@ -2930,17 +2871,21 @@ window.onload = async function () {
window.editCategory = async function(categoryId) {
try {
- // Fetch category details
- const res = await fetch(`/api/categories/list`, {
+ // Fetch category directly by ID
+ const res = await fetch(`/api/categories/${categoryId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
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 category = data.categories.find(c => c._id === categoryId);
+ const category = data.category;
if (!category) {
showResponse('danger', 'Catégorie non trouvée');
@@ -3019,10 +2964,27 @@ window.onload = async function () {
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 {
saveCategoryChangesBtn.disabled = true;
- const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
saveCategoryChangesBtn.textContent = 'Enregistrement...';
+
+ // Hide any previous alerts
+ if (alertEl) {
+ alertEl.classList.add('d-none');
+ }
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'PUT',
@@ -3031,6 +2993,7 @@ window.onload = async function () {
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
+ categoryNumber,
categoryName,
nominees
})
@@ -3051,12 +3014,19 @@ window.onload = async function () {
// Reload list
await loadCategoriesList();
} catch (err) {
+ // Show error in modal alert
if (alertEl) {
alertEl.className = 'alert alert-danger';
alertEl.textContent = 'Erreur: ' + err.message;
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 {
+ // Always restore button state
saveCategoryChangesBtn.disabled = false;
saveCategoryChangesBtn.textContent = oldText;
}
@@ -3079,5 +3049,104 @@ window.onload = async function () {
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();
+ }
};
diff --git a/public/js/app_picks.js b/public/js/app_picks.js
index 4b6fd51..e479e5f 100644
--- a/public/js/app_picks.js
+++ b/public/js/app_picks.js
@@ -39,9 +39,8 @@
let categories = [];
let myPicks = {};
- let savedPicks = {}; // Track saved state to detect changes
let currentYear = null;
- let hasUnsavedChanges = false;
+ let isSaving = false; // Prevent multiple simultaneous saves
async function fetchActiveYear() {
try {
@@ -133,20 +132,17 @@
const data = await res.json();
if (data.pick && data.pick.picks) {
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 => {
- myPicks[p.categoryNumber] = p.selectedNominee;
- savedPicks[p.categoryNumber] = p.selectedNominee; // Store saved state
+ if (existingCategoryNumbers.has(p.categoryNumber)) {
+ myPicks[p.categoryNumber] = p.selectedNominee;
+ }
});
- hasUnsavedChanges = false;
renderCategories();
updateProgress();
- updateUnsavedChangesIndicator();
- } else {
- // No picks saved yet
- savedPicks = {};
- hasUnsavedChanges = false;
- updateUnsavedChangesIndicator();
}
} catch (err) {
console.error('Error loading picks:', err);
@@ -227,71 +223,41 @@
// Re-render to update the display
renderCategories();
- checkForUnsavedChanges();
+
+ // Auto-save after selection change
+ autoSavePicks();
});
});
updateProgress();
}
- function checkForUnsavedChanges() {
- // Compare current picks with saved picks
- const currentKeys = Object.keys(myPicks).sort();
- const savedKeys = Object.keys(savedPicks).sort();
-
- // Check if counts differ
- if (currentKeys.length !== savedKeys.length) {
- hasUnsavedChanges = true;
- updateUnsavedChangesIndicator();
- return;
+ async function autoSavePicks() {
+ // Clear any existing timeout to reset the debounce
+ if (autoSavePicks.timeout) {
+ clearTimeout(autoSavePicks.timeout);
}
- // Check if any values differ or keys are different
- const allKeys = new Set([...currentKeys, ...savedKeys]);
- for (const key of allKeys) {
- if (myPicks[key] !== savedPicks[key]) {
- hasUnsavedChanges = true;
- updateUnsavedChangesIndicator();
+ // Debounce: wait for user to stop clicking before saving
+ autoSavePicks.timeout = setTimeout(async () => {
+ // Skip if already saving (will retry after current save completes)
+ if (isSaving) {
+ // Retry after a short delay
+ setTimeout(() => autoSavePicks(), 200);
return;
}
- }
-
- // No changes detected
- hasUnsavedChanges = false;
- updateUnsavedChangesIndicator();
- }
-
- function updateUnsavedChangesIndicator() {
- const submitBtn = document.getElementById('submit-picks');
- const banner = document.getElementById('unsaved-changes-banner');
-
- if (hasUnsavedChanges) {
- // Enable button and change style
- if (submitBtn) {
- submitBtn.disabled = false;
- submitBtn.classList.add('btn-warning');
- submitBtn.classList.remove('btn-primary');
+
+ isSaving = true;
+ try {
+ await submitPicks(true); // Pass true for silent mode (no alert)
+ } catch (err) {
+ console.error('Auto-save failed:', err);
+ // Show error alert only on failure
+ showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
+ } finally {
+ isSaving = false;
}
- // Show banner
- 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');
- }
- }
+ }, 250); // Reduced to 250ms for better responsiveness
}
function updateProgress() {
@@ -300,18 +266,20 @@
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
}
- async function submitPicks() {
+ async function submitPicks(silent = false) {
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;
+ // Allow empty picks to be saved (to clear all selections)
+ // Only show warning in non-silent mode if user explicitly tries to submit with no picks
+ 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?`)) {
return;
}
@@ -336,11 +304,9 @@
}
const data = await res.json();
- // Update saved state after successful save
- savedPicks = { ...myPicks };
- hasUnsavedChanges = false;
- updateUnsavedChangesIndicator();
- showAlert('Vos choix ont été enregistrés avec succès!', 'success');
+ if (!silent) {
+ 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');
@@ -348,7 +314,6 @@
}
// Event listeners
- document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
@@ -599,7 +564,6 @@
Utilisateur
Score
Total
-
%
Actions
@@ -607,7 +571,6 @@
${validScores.map((score, index) => {
// Use the pre-calculated actualScore (always use it, even if 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 medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
return `
@@ -616,7 +579,6 @@
${score.userName}
${actualScore}
${totalCategories}
-
${percentage}%
Détails
@@ -746,7 +708,6 @@
}
// 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');
@@ -755,14 +716,6 @@
// Load scores when scores tab is shown
const scoresTab = document.getElementById('tab-scores');
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 () => {
await fetchScores();
});
@@ -771,14 +724,6 @@
// Load winners when admin tab is shown
const winnersTab = document.getElementById('tab-winners');
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 () => {
await fetchWinnersCategories();
// Auto-refresh scores when entering the winners tab (silent mode)
@@ -792,44 +737,8 @@
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
(async () => {
- // Initialize button state (disabled by default)
- updateUnsavedChangesIndicator();
-
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
await fetchCategories();
diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js
index 44a9a27..0171550 100644
--- a/public/js/app_user-stat.js
+++ b/public/js/app_user-stat.js
@@ -668,16 +668,23 @@ window.addEventListener('DOMContentLoaded', async function () {
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
}
- // Sort by total points (descending), then by watched ratio
+ // Sort by total points (descending)
stats.sort((a, b) => {
- const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
- if (pointsDiff !== 0) return pointsDiff;
- return parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio);
+ return (b.totalPoints || 0) - (a.totalPoints || 0);
});
// Clear previous rows to prevent duplication
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 (${totalMovies}) `;
+ }
+
stats.forEach((userStat, index) => {
const userRow = document.createElement('tr');
const rank = index + 1;
@@ -712,15 +719,11 @@ window.addEventListener('DOMContentLoaded', async function () {
pointsTd.className = 'text-center';
pointsTd.innerHTML = `${userStat.totalPoints || 0} `;
- 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);
});
diff --git a/public/picks.html b/public/picks.html
index 14c37e0..269c924 100644
--- a/public/picks.html
+++ b/public/picks.html
@@ -168,15 +168,9 @@
Recharger
- Enregistrer
-
- ⚠️ Modifications en attente: Vous avez des modifications non enregistrées. N'oubliez pas d'enregistrer vos choix avant de quitter cette page.
-
-
-
Chargement des catégories…
@@ -206,7 +200,6 @@
0 / 0 gagnants marqués
- Calculer
Rafraîchir
diff --git a/public/user-stat.html b/public/user-stat.html
index d933144..a4383b6 100644
--- a/public/user-stat.html
+++ b/public/user-stat.html
@@ -111,10 +111,9 @@
#
Nom
- Films
+
- Points
- Ratio
+ Points
diff --git a/routes/categories.js b/routes/categories.js
index c439162..fb6ce30 100644
--- a/routes/categories.js
+++ b/routes/categories.js
@@ -1,26 +1,12 @@
const express = require("express");
const jwt = require("jsonwebtoken");
-const multer = require("multer");
const path = require("path");
const fs = require("fs");
-const { PDFParse } = require("pdf-parse");
const OscarCategory = require("../models/OscarCategory");
+const OscarPick = require("../models/OscarPick");
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
function verifyToken(req, res, next) {
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
function isValidNomineeLine(line, skipPatterns) {
- // Skip if it looks like a category header
- if (line.match(/^(.+?)[\t\s]+\d+$/) || line.match(/^\d+\./)) {
+ // Skip if it looks like a category header (number first or name first with number)
+ 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;
}
@@ -74,6 +62,11 @@ function isValidNomineeLine(line, skipPatterns) {
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();
// 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)$/) ||
@@ -83,218 +76,7 @@ function isValidNomineeLine(line, skipPatterns) {
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)
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)
router.put("/:categoryId", verifyToken, async (req, res) => {
try {
@@ -359,23 +161,96 @@ router.put("/:categoryId", verifyToken, async (req, res) => {
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) {
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 => ({
name: typeof n === 'string' ? n : n.name,
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(
req.params.categoryId,
- {
- categoryName,
- nominees: nomineesArray
- },
+ updateData,
{ new: true }
);
@@ -398,15 +273,23 @@ router.put("/:categoryId/winner", verifyToken, async (req, res) => {
}
const { winnerName } = req.body;
- if (!winnerName) {
- return res.status(400).json({ message: "winnerName is required" });
- }
+ const isClearing = !winnerName || winnerName === '' || winnerName === null;
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
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
category.nominees = category.nominees.map(n => ({
...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)
router.delete("/:categoryId", verifyToken, async (req, res) => {
try {
@@ -429,11 +349,19 @@ router.delete("/:categoryId", verifyToken, async (req, res) => {
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) {
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" });
} catch (err) {
console.error("Error deleting category:", err);
diff --git a/routes/picks.js b/routes/picks.js
index 8b71bd9..d660707 100644
--- a/routes/picks.js
+++ b/routes/picks.js
@@ -68,8 +68,8 @@ router.post("/submit", verifyToken, async (req, res) => {
const userId = req.user.id;
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
- if (!Array.isArray(picks) || picks.length === 0) {
- return res.status(400).json({ message: "Picks array is required" });
+ if (!Array.isArray(picks)) {
+ return res.status(400).json({ message: "Picks must be an array" });
}
// 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." });
}
- // Validate picks
+ // Filter and validate picks - remove picks for deleted categories
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
- for (const pick of picks) {
- if (!pick.categoryNumber || !pick.selectedNominee) {
- return res.status(400).json({ message: "Each pick must have categoryNumber and selectedNominee" });
- }
- const category = categoryMap.get(pick.categoryNumber);
- if (!category) {
- return res.status(400).json({ message: `Category ${pick.categoryNumber} not found` });
- }
- const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
- if (!nomineeExists) {
- return res.status(400).json({ message: `Nominee "${pick.selectedNominee}" not found in category ${pick.categoryNumber}` });
+ const validPicks = [];
+
+ if (picks.length > 0) {
+ for (const pick of picks) {
+ if (!pick.categoryNumber || !pick.selectedNominee) {
+ continue; // Skip invalid picks
+ }
+ const category = categoryMap.get(pick.categoryNumber);
+ if (!category) {
+ // Category was deleted - skip this pick
+ 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 = {
userId,
year: yearNum,
- picks: picks.map(p => ({
- categoryNumber: p.categoryNumber,
- categoryName: categoryMap.get(p.categoryNumber).categoryName,
- selectedNominee: p.selectedNominee
- }))
+ picks: validPicks
};
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 year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
- const pick = await OscarPick.findOne({ userId, year })
- .populate('userId', 'name email')
- .lean();
+ const pick = await OscarPick.findOne({ userId, year });
if (!pick) {
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) {
console.error("Error fetching user picks:", err);
res.status(500).json({ error: err.message });
@@ -199,13 +223,23 @@ router.post("/calculate-scores", verifyToken, async (req, res) => {
.populate('userId', 'name email')
.lean();
+ // Create a map of all categories for reference
+ const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
+
// Calculate scores
const scores = [];
for (const pick of allPicks) {
let correctCount = 0;
const pickDetails = [];
+ // Only process picks for categories that still exist
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 isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
@@ -219,8 +253,12 @@ router.post("/calculate-scores", verifyToken, async (req, res) => {
});
});
- // Update the pick with score
- await OscarPick.findByIdAndUpdate(pick._id, { score: correctCount });
+ // Update the pick with score and clean up deleted category picks
+ const validPicks = pick.picks.filter(p => categoryMap.has(p.categoryNumber));
+ await OscarPick.findByIdAndUpdate(pick._id, {
+ score: correctCount,
+ picks: validPicks
+ });
scores.push({
userId: pick.userId?._id || pick.userId,
@@ -253,12 +291,33 @@ router.get("/scores", verifyToken, async (req, res) => {
try {
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 })
.populate('userId', 'name email')
.sort({ score: -1, submittedAt: 1 })
.lean();
- const categories = await OscarCategory.find({ year }).lean();
+ // Reuse categories already fetched for cleanup
const categoryWinners = new Map();
categories.forEach(cat => {
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
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
+ // Only process picks for categories that still exist
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 isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
- const category = categoryMap.get(p.categoryNumber);
- const allNominees = category ? category.nominees.map(n => n.name) : [];
+ const allNominees = category.nominees.map(n => n.name);
pickDetails.push({
categoryNumber: p.categoryNumber,
@@ -296,7 +361,7 @@ router.get("/scores", verifyToken, async (req, res) => {
userId: pick.userId?._id || pick.userId,
userName: pick.userId?.name || 'Unknown',
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,
pickDetails,
submittedAt: pick.submittedAt
diff --git a/uploads/58f9b0fe6557029827201d966a91c8de b/uploads/58f9b0fe6557029827201d966a91c8de
new file mode 100644
index 0000000..3c22fb3
Binary files /dev/null and b/uploads/58f9b0fe6557029827201d966a91c8de differ
diff --git a/uploads/f053337f2e65c8f7651ba46febc4fc1f b/uploads/f053337f2e65c8f7651ba46febc4fc1f
new file mode 100644
index 0000000..3c22fb3
Binary files /dev/null and b/uploads/f053337f2e65c8f7651ba46febc4fc1f differ