WIP: Add points system for movies and picks, admin configuration, and stats page updates

This commit is contained in:
quoije
2025-12-26 12:37:30 -05:00
parent 7722ac89c3
commit 1281d77c4e
22 changed files with 4632 additions and 9 deletions
Vendored
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -25,6 +25,8 @@ const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings"); const settingsRoutes = require("./routes/settings");
const adminDbRoutes = require("./routes/adminDb"); const adminDbRoutes = require("./routes/adminDb");
const videoRoutes = require("./routes/video"); const videoRoutes = require("./routes/video");
const picksRoutes = require("./routes/picks");
const categoriesRoutes = require("./routes/categories");
app.use(express.static('public')); app.use(express.static('public'));
app.use("/api/users", userRoutes); app.use("/api/users", userRoutes);
@@ -33,7 +35,9 @@ app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes); app.use("/api/settings", settingsRoutes);
app.use("/api/admin", adminDbRoutes); app.use("/api/admin", adminDbRoutes);
app.use("/api/video", videoRoutes); app.use("/api/video", videoRoutes);
app.use("/api/picks", picksRoutes);
app.use("/api/categories", categoriesRoutes);
// Start Server // Start Server
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5050;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
+19
View File
@@ -0,0 +1,19 @@
const mongoose = require("mongoose");
const oscarCategorySchema = new mongoose.Schema({
year: { type: Number, required: true, index: true },
categoryNumber: { type: Number, required: true }, // 1-23 from the sheet
categoryName: { type: String, required: true }, // e.g., "Best Picture"
nominees: [{
name: { type: String, required: true }, // e.g., "Anora" or "Sean Baker, Anora"
isWinner: { type: Boolean, default: false } // Set when results are known
}],
// Store the raw text from PDF for reference
rawText: { type: String, required: false }
}, { timestamps: true });
// Ensure unique category per year
oscarCategorySchema.index({ year: 1, categoryNumber: 1 }, { unique: true });
module.exports = mongoose.model("OscarCategory", oscarCategorySchema);
+19
View File
@@ -0,0 +1,19 @@
const mongoose = require("mongoose");
const oscarPickSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
year: { type: Number, required: true, index: true },
picks: [{
categoryNumber: { type: Number, required: true },
categoryName: { type: String, required: true },
selectedNominee: { type: String, required: true } // The nominee name they selected
}],
submittedAt: { type: Date, default: Date.now },
score: { type: Number, default: null } // Calculated after winners are set
}, { timestamps: true });
// Ensure one pick submission per user per year
oscarPickSchema.index({ userId: 1, year: 1 }, { unique: true });
module.exports = mongoose.model("OscarPick", oscarPickSchema);
+218 -1
View File
@@ -19,7 +19,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"mongoose": "^8.9.5", "mongoose": "^8.9.5",
"multer": "^2.0.2" "multer": "^2.0.2",
"pdf-parse": "^2.4.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9" "nodemon": "^3.1.9"
@@ -34,6 +35,190 @@
"sparse-bitfield": "^3.0.3" "sparse-bitfield": "^3.0.3"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@types/webidl-conversions": { "node_modules/@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -1432,6 +1617,38 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+2 -1
View File
@@ -20,7 +20,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"mongoose": "^8.9.5", "mongoose": "^8.9.5",
"multer": "^2.0.2" "multer": "^2.0.2",
"pdf-parse": "^2.4.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9" "nodemon": "^3.1.9"
+1
View File
@@ -72,6 +72,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a> <a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
+240
View File
@@ -43,6 +43,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a> <a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
</div> </div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center"> <div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
@@ -87,6 +88,11 @@
Gagnants Gagnants
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-categories-tab" data-bs-toggle="tab" data-bs-target="#admin-categories" type="button" role="tab" aria-controls="admin-categories" aria-selected="false">
Catégories Oscars
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false"> <button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
Backup / Restore Backup / Restore
@@ -125,6 +131,11 @@
Date Oscars Date Oscars
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-settings-subtab-points" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-points" type="button" role="tab" aria-controls="admin-settings-pane-points" aria-selected="false">
Points
</button>
</li>
</ul> </ul>
<div class="tab-content pt-3"> <div class="tab-content pt-3">
@@ -194,6 +205,46 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Points configuration -->
<div class="tab-pane fade" id="admin-settings-pane-points" role="tabpanel" aria-labelledby="admin-settings-subtab-points" tabindex="0">
<div class="border rounded p-3 bg-light">
<div class="text-muted small mb-3">
Configure les points attribués pour les films regardés et les bonnes réponses aux Oscars.
</div>
<div id="points-config-alert" class="alert d-none" role="alert"></div>
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="points-per-movie" class="form-label">Points par film regardé</label>
<input type="number" class="form-control" id="points-per-movie" min="0" step="1" value="1">
<div class="form-text">Points attribués pour chaque film complété.</div>
</div>
<div class="col-12 col-md-6">
<label for="points-per-correct-pick" class="form-label">Points par bonne réponse</label>
<input type="number" class="form-control" id="points-per-correct-pick" min="0" step="1" value="1">
<div class="form-text">Points attribués pour chaque bonne réponse aux Oscars.</div>
</div>
</div>
<div class="mt-3">
<div class="d-flex gap-2">
<button type="button" id="points-config-reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="points-config-save" class="btn btn-warning">Enregistrer</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Calcul des points</div>
<div class="border rounded p-2 bg-white">
<div class="small">
<strong>Total =</strong> (Films regardés × <span id="points-per-movie-preview">1</span>) + (Bonnes réponses × <span id="points-per-correct-pick-preview">1</span>)
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -805,6 +856,158 @@
</div> </div>
</div> </div>
<div class="tab-pane fade" id="admin-categories" role="tabpanel" aria-labelledby="admin-categories-tab">
<div class="card p-3">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<div>
<h4 class="mb-1">Catégories Oscars</h4>
<div class="text-muted">Importez les catégories depuis un PDF ou créez-les manuellement.</div>
</div>
</div>
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCategoriesSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-categories-subtab-import" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-import" type="button" role="tab" aria-controls="admin-categories-pane-import" aria-selected="true">
Import PDF
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-categories-subtab-manual" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-manual" type="button" role="tab" aria-controls="admin-categories-pane-manual" aria-selected="false">
Création manuelle
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-categories-subtab-list" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-list" type="button" role="tab" aria-controls="admin-categories-pane-list" aria-selected="false">
Liste
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Import PDF -->
<div class="tab-pane fade show active" id="admin-categories-pane-import" role="tabpanel" aria-labelledby="admin-categories-subtab-import" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1">Importer depuis un PDF</div>
<div class="text-muted small mb-3">
Téléchargez le fichier PDF de la feuille de props Oscars. Le système extraira automatiquement les catégories et les nommés.
</div>
<div id="categories-import-alert" class="alert d-none" role="alert"></div>
<form id="import-pdf-form">
<div class="mb-3">
<label for="categories-year" class="form-label">Année</label>
<select class="form-select" id="categories-year" required>
<option value="">Chargement...</option>
</select>
<div class="form-text">Année des Oscars pour ces catégories.</div>
</div>
<div class="mb-3">
<label for="pdf-file" class="form-label">Fichier PDF</label>
<input type="file" class="form-control" id="pdf-file" accept=".pdf,application/pdf" required>
<div class="form-text">Format attendu: feuille de props Oscars avec catégories numérotées.</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="submit" class="btn btn-warning">Importer</button>
</div>
</form>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Astuce</div>
<div class="text-muted">
Le PDF doit contenir des catégories numérotées (1., 2., etc.) suivies de leurs nommés.
Vérifiez la liste après import pour corriger si nécessaire.
</div>
</div>
</div>
</div>
</div>
<!-- Manual creation -->
<div class="tab-pane fade" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1">Créer une catégorie manuellement</div>
<div class="text-muted small mb-3">
Ajoutez une catégorie et ses nommés un par un.
</div>
<div id="categories-manual-alert" class="alert d-none" role="alert"></div>
<form id="create-category-form">
<div class="mb-3">
<label for="manual-year" class="form-label">Année</label>
<select class="form-select" id="manual-year" required>
<option value="">Chargement...</option>
</select>
</div>
<div class="mb-3">
<label for="category-number" class="form-label">Numéro de catégorie</label>
<input type="number" class="form-control" id="category-number" placeholder="1" min="1" max="50" required>
</div>
<div class="mb-3">
<label for="category-name" class="form-label">Nom de la catégorie</label>
<input type="text" class="form-control" id="category-name" placeholder="Best Picture" required>
</div>
<div class="mb-3">
<label for="nominees-list" class="form-label">Nommés (un par ligne)</label>
<textarea class="form-control" id="nominees-list" rows="10" placeholder="Anora&#10;The Brutalist&#10;Dune: Part Two" required></textarea>
<div class="form-text">Entrez un nommé par ligne.</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="submit" class="btn btn-primary">Créer</button>
</div>
</form>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Note</div>
<div class="text-muted">
Vous pouvez créer plusieurs catégories pour la même année.
Le numéro de catégorie doit être unique par année.
</div>
</div>
</div>
</div>
</div>
<!-- List -->
<div class="tab-pane fade" id="admin-categories-pane-list" role="tabpanel" aria-labelledby="admin-categories-subtab-list" tabindex="0">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">Catégories importées</div>
<div class="text-muted small">Gérez les catégories existantes</div>
</div>
<div class="d-flex gap-2">
<select class="form-select" id="list-year-filter" style="width: 150px;">
<option value="">Toutes les années</option>
</select>
<button type="button" id="refresh-categories-list" class="btn btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div id="categories-list-container" class="border rounded bg-white p-3">
<div class="text-muted">Charge la liste…</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab"> <div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
<div class="card p-3"> <div class="card p-3">
<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">
@@ -1032,6 +1235,43 @@
</div> </div>
</div> </div>
<!-- Edit category modal -->
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modifier la catégorie</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit_category_id">
<div class="mb-3">
<label for="edit_category_number" class="form-label">Numéro de catégorie</label>
<input type="number" class="form-control" id="edit_category_number" readonly>
</div>
<div class="mb-3">
<label for="edit_category_name" class="form-label">Nom de la catégorie</label>
<input type="text" class="form-control" id="edit_category_name" required>
</div>
<div class="mb-3">
<label for="edit_nominees_list" class="form-label">Nommés (un par ligne)</label>
<textarea class="form-control" id="edit_nominees_list" rows="10" required></textarea>
<div class="form-text">Entrez un nommé par ligne.</div>
</div>
<div id="edit-category-alert" class="alert d-none" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-primary" id="save-category-changes">Enregistrer</button>
</div>
</div>
</div>
</div>
<!-- Edit movie modal --> <!-- Edit movie modal -->
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
+510
View File
@@ -2094,6 +2094,97 @@ window.onload = async function () {
// Winners year dropdown (optional tab) // Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears); setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache(); applyWinnerFormFromCache();
// Categories year dropdowns
const categoriesYearSelect = document.getElementById('categories-year');
const manualYearSelect = document.getElementById('manual-year');
const listYearFilterSelect = document.getElementById('list-year-filter');
// Populate PDF import year dropdown
if (categoriesYearSelect) {
const previousCategoriesYear = categoriesYearSelect.value;
categoriesYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune année (ajoute un film)';
opt.disabled = true;
categoriesYearSelect.appendChild(opt);
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
categoriesYearSelect.appendChild(opt);
});
const desired = previousCategoriesYear && cleanedYears.map(String).includes(previousCategoriesYear)
? previousCategoriesYear
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
if (desired) categoriesYearSelect.value = desired;
}
}
// Populate manual creation year dropdown
if (manualYearSelect) {
const previousManualYear = manualYearSelect.value;
manualYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune année (ajoute un film)';
opt.disabled = true;
manualYearSelect.appendChild(opt);
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
manualYearSelect.appendChild(opt);
});
const desired = previousManualYear && cleanedYears.map(String).includes(previousManualYear)
? previousManualYear
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
if (desired) manualYearSelect.value = desired;
}
}
// Populate list filter year dropdown
if (listYearFilterSelect) {
const previousListYear = listYearFilterSelect.value;
listYearFilterSelect.innerHTML = '';
// Add "All years" option
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = 'Toutes les années';
listYearFilterSelect.appendChild(allOpt);
if (cleanedYears.length > 0) {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
listYearFilterSelect.appendChild(opt);
});
// Keep previous selection if still valid
if (previousListYear && cleanedYears.map(String).includes(previousListYear)) {
listYearFilterSelect.value = previousListYear;
} else if (activeYear && cleanedYears.map(String).includes(String(activeYear))) {
listYearFilterSelect.value = String(activeYear);
} else {
listYearFilterSelect.value = '';
}
}
}
} catch (_) { } catch (_) {
// ignore // ignore
} }
@@ -2485,5 +2576,424 @@ window.onload = async function () {
await refreshYears(); await refreshYears();
await loadMoviesForManagement(); await loadMoviesForManagement();
// Points configuration
const pointsPerMovieInput = document.getElementById('points-per-movie');
const pointsPerCorrectPickInput = document.getElementById('points-per-correct-pick');
const pointsConfigReloadBtn = document.getElementById('points-config-reload');
const pointsConfigSaveBtn = document.getElementById('points-config-save');
const pointsConfigAlert = document.getElementById('points-config-alert');
const pointsPerMoviePreview = document.getElementById('points-per-movie-preview');
const pointsPerCorrectPickPreview = document.getElementById('points-per-correct-pick-preview');
async function loadPointsConfig() {
try {
const res = await fetch('/api/settings/points-config', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load points config');
const config = await res.json();
if (pointsPerMovieInput) pointsPerMovieInput.value = config.pointsPerMovie || 1;
if (pointsPerCorrectPickInput) pointsPerCorrectPickInput.value = config.pointsPerCorrectPick || 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = config.pointsPerMovie || 1;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = config.pointsPerCorrectPick || 1;
} catch (err) {
console.error('Error loading points config:', err);
}
}
function updatePointsPreview() {
const moviePoints = pointsPerMovieInput ? parseInt(pointsPerMovieInput.value) || 1 : 1;
const pickPoints = pointsPerCorrectPickInput ? parseInt(pointsPerCorrectPickInput.value) || 1 : 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = moviePoints;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = pickPoints;
}
if (pointsPerMovieInput) {
pointsPerMovieInput.addEventListener('input', updatePointsPreview);
}
if (pointsPerCorrectPickInput) {
pointsPerCorrectPickInput.addEventListener('input', updatePointsPreview);
}
if (pointsConfigReloadBtn) {
pointsConfigReloadBtn.addEventListener('click', async () => {
await loadPointsConfig();
showResponse('success', 'Configuration rechargée');
});
}
if (pointsConfigSaveBtn) {
pointsConfigSaveBtn.addEventListener('click', async () => {
const pointsPerMovie = parseInt(pointsPerMovieInput?.value) || 1;
const pointsPerCorrectPick = parseInt(pointsPerCorrectPickInput?.value) || 1;
if (pointsPerMovie < 0 || pointsPerCorrectPick < 0) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-warning';
pointsConfigAlert.textContent = 'Les points doivent être positifs ou nuls';
pointsConfigAlert.classList.remove('d-none');
}
return;
}
try {
pointsConfigSaveBtn.disabled = true;
const oldText = pointsConfigSaveBtn.textContent;
pointsConfigSaveBtn.textContent = 'Enregistrement...';
const res = await fetch('/api/settings/points-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
pointsPerMovie,
pointsPerCorrectPick
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to save');
}
const data = await res.json();
updatePointsPreview();
showResponse('success', `Configuration enregistrée: ${data.pointsPerMovie} pts/film, ${data.pointsPerCorrectPick} pts/bonne réponse`);
} catch (err) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-danger';
pointsConfigAlert.textContent = 'Erreur: ' + err.message;
pointsConfigAlert.classList.remove('d-none');
}
} finally {
pointsConfigSaveBtn.disabled = false;
pointsConfigSaveBtn.textContent = oldText;
}
});
}
await loadPointsConfig();
// Categories management
const importPdfForm = document.getElementById('import-pdf-form');
const createCategoryForm = document.getElementById('create-category-form');
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
const categoriesListContainer = document.getElementById('categories-list-container');
const listYearFilter = document.getElementById('list-year-filter');
function showCategoriesAlert(message, type = 'info') {
const alertEl = document.getElementById('categories-import-alert') || document.getElementById('categories-manual-alert');
if (!alertEl) return;
alertEl.className = `alert alert-${type}`;
alertEl.textContent = message;
alertEl.classList.remove('d-none');
setTimeout(() => {
alertEl.classList.add('d-none');
}, 5000);
}
if (importPdfForm) {
importPdfForm.addEventListener('submit', async (e) => {
e.preventDefault();
const year = parseYear(document.getElementById('categories-year').value);
const fileInput = document.getElementById('pdf-file');
const file = fileInput.files[0];
if (!file || !year) {
showCategoriesAlert('Veuillez remplir tous les champs', 'warning');
return;
}
const formData = new FormData();
formData.append('pdf', file);
formData.append('year', year);
try {
const res = await fetch('/api/categories/import-pdf', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Import failed');
}
const data = await res.json();
showCategoriesAlert(`Import réussi: ${data.categories.length} catégories importées`, 'success');
fileInput.value = '';
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
} catch (err) {
showCategoriesAlert('Erreur: ' + err.message, 'danger');
}
});
}
if (createCategoryForm) {
createCategoryForm.addEventListener('submit', async (e) => {
e.preventDefault();
const year = parseYear(document.getElementById('manual-year').value);
const categoryNumber = parseInt(document.getElementById('category-number').value);
const categoryName = document.getElementById('category-name').value.trim();
const nomineesText = document.getElementById('nominees-list').value.trim();
if (!year || !categoryNumber || !categoryName || !nomineesText) {
showCategoriesAlert('Veuillez remplir tous les champs', 'warning');
return;
}
const nominees = nomineesText.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (nominees.length === 0) {
showCategoriesAlert('Veuillez entrer au moins un nommé', 'warning');
return;
}
try {
const res = await fetch('/api/categories/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year,
categoryNumber,
categoryName,
nominees
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Creation failed');
}
const data = await res.json();
showCategoriesAlert('Catégorie créée avec succès', 'success');
createCategoryForm.reset();
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
} catch (err) {
showCategoriesAlert('Erreur: ' + err.message, 'danger');
}
});
}
async function loadCategoriesList() {
if (!categoriesListContainer) return;
const year = listYearFilter?.value ? parseYear(listYearFilter.value) : null;
const url = year ? `/api/categories/list?year=${year}` : '/api/categories/list';
try {
categoriesListContainer.innerHTML = '<div class="text-muted">Chargement…</div>';
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to load categories');
}
const data = await res.json();
const categories = data.categories || [];
if (categories.length === 0) {
categoriesListContainer.innerHTML = `<div class="text-muted">Aucune catégorie trouvée${year ? ` pour l'année ${year}` : ''}.</div>`;
return;
}
categoriesListContainer.innerHTML = `
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>#</th>
<th>Nom</th>
<th>Nommés</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${categories.map(cat => `
<tr>
<td>${cat.categoryNumber}</td>
<td><strong>${cat.categoryName}</strong></td>
<td>
<ul class="mb-0 small">
${cat.nominees.map(n => `<li>${n.name}</li>`).join('')}
</ul>
</td>
<td>
<button class="btn btn-sm btn-primary me-1" onclick="editCategory('${cat._id}')">Éditer</button>
<button class="btn btn-sm btn-danger" onclick="deleteCategory('${cat._id}')">Supprimer</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} catch (err) {
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
}
}
window.editCategory = async function(categoryId) {
try {
// Fetch category details
const res = await fetch(`/api/categories/list`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to load categories');
}
const data = await res.json();
const category = data.categories.find(c => c._id === categoryId);
if (!category) {
showResponse('danger', 'Catégorie non trouvée');
return;
}
// Populate modal
document.getElementById('edit_category_id').value = categoryId;
document.getElementById('edit_category_number').value = category.categoryNumber;
document.getElementById('edit_category_name').value = category.categoryName;
document.getElementById('edit_nominees_list').value = category.nominees.map(n => n.name).join('\n');
// Hide alert
const alertEl = document.getElementById('edit-category-alert');
if (alertEl) {
alertEl.classList.add('d-none');
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editCategoryModal'));
modal.show();
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
}
};
window.deleteCategory = async function(categoryId) {
if (!confirm('Êtes-vous sûr de vouloir supprimer cette catégorie?')) return;
try {
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Delete failed');
}
showResponse('success', 'Catégorie supprimée');
await loadCategoriesList();
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
}
};
// Save category changes
const saveCategoryChangesBtn = document.getElementById('save-category-changes');
if (saveCategoryChangesBtn) {
saveCategoryChangesBtn.addEventListener('click', async () => {
const categoryId = document.getElementById('edit_category_id').value;
const categoryName = document.getElementById('edit_category_name').value.trim();
const nomineesText = document.getElementById('edit_nominees_list').value.trim();
const alertEl = document.getElementById('edit-category-alert');
if (!categoryName || !nomineesText) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = 'Veuillez remplir tous les champs';
alertEl.classList.remove('d-none');
}
return;
}
const nominees = nomineesText.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (nominees.length === 0) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = 'Veuillez entrer au moins un nommé';
alertEl.classList.remove('d-none');
}
return;
}
try {
saveCategoryChangesBtn.disabled = true;
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
saveCategoryChangesBtn.textContent = 'Enregistrement...';
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
categoryName,
nominees
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Update failed');
}
const data = await res.json();
showResponse('success', 'Catégorie mise à jour avec succès');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editCategoryModal'));
if (modal) modal.hide();
// Reload list
await loadCategoriesList();
} catch (err) {
if (alertEl) {
alertEl.className = 'alert alert-danger';
alertEl.textContent = 'Erreur: ' + err.message;
alertEl.classList.remove('d-none');
}
} finally {
saveCategoryChangesBtn.disabled = false;
saveCategoryChangesBtn.textContent = oldText;
}
});
}
if (refreshCategoriesListBtn) {
refreshCategoriesListBtn.addEventListener('click', loadCategoriesList);
// Auto-reload when year filter changes
if (listYearFilter) {
listYearFilter.addEventListener('change', loadCategoriesList);
}
}
// Auto-load when tab is shown
const categoriesTabBtn = document.getElementById('admin-categories-tab');
if (categoriesTabBtn) {
categoriesTabBtn.addEventListener('shown.bs.tab', () => {
loadCategoriesList();
});
}
}; };
+741
View File
@@ -0,0 +1,741 @@
(function() {
'use strict';
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
const decoded = decodeJwt(token);
if (!decoded) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Check if admin and show admin tabs
if (decoded.admin) {
const adminTabAllPicks = document.getElementById('tab-all-picks');
if (adminTabAllPicks) adminTabAllPicks.classList.remove('d-none');
const adminTabWinners = document.getElementById('tab-winners');
if (adminTabWinners) adminTabWinners.classList.remove('d-none');
}
// Logout
const logOffBtn = document.getElementById('log-off');
if (logOffBtn) {
logOffBtn.addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
let categories = [];
let myPicks = {};
let currentYear = null;
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year');
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
return Number(data.year) || new Date().getFullYear();
} catch (err) {
console.error('Error fetching active year:', err);
return new Date().getFullYear();
}
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
async function fetchCategories() {
try {
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
}
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
throw new Error('Failed to fetch categories');
}
const data = await res.json();
categories = data.categories || [];
currentYear = data.year || currentYear;
document.getElementById('current-year').textContent = currentYear;
if (document.getElementById('all-picks-year')) {
document.getElementById('all-picks-year').textContent = currentYear;
}
if (categories.length === 0) {
document.getElementById('categories-container').innerHTML = `
<div class="alert alert-warning">
Aucune catégorie trouvée pour l'année ${currentYear}.
Un administrateur doit d'abord importer les catégories.
</div>
`;
return;
}
renderCategories();
await loadMyPicks();
} catch (err) {
console.error('Error fetching categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
}
}
async function loadMyPicks() {
try {
const res = await fetch(`/api/picks/my-picks?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok && res.status !== 404) {
throw new Error('Failed to load picks');
}
const data = await res.json();
if (data.pick && data.pick.picks) {
myPicks = {};
data.pick.picks.forEach(p => {
myPicks[p.categoryNumber] = p.selectedNominee;
});
renderCategories();
updateProgress();
}
} catch (err) {
console.error('Error loading picks:', err);
}
}
function renderCategories() {
const container = document.getElementById('categories-container');
if (!container) return;
if (categories.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-muted">Aucune catégorie disponible</div>';
return;
}
container.innerHTML = categories.map(cat => `
<div class="card category-card shadow-sm">
<div class="card-header">
<h5 class="mb-0">${cat.categoryNumber}. ${cat.categoryName}</h5>
</div>
<div class="card-body">
<div class="row g-2">
${cat.nominees.map(nominee => {
const isSelected = myPicks[cat.categoryNumber] === nominee.name;
return `
<div class="col-12 col-md-6 col-lg-4">
<div class="nominee-option p-3 border rounded ${isSelected ? 'selected' : ''}"
data-category="${cat.categoryNumber}"
data-nominee="${nominee.name}">
${nominee.name}
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
`).join('');
// Add click handlers
container.querySelectorAll('.nominee-option').forEach(el => {
el.addEventListener('click', function() {
const category = parseInt(this.dataset.category);
const nominee = this.dataset.nominee;
// Remove selection from other options in same category
container.querySelectorAll(`[data-category="${category}"]`).forEach(opt => {
opt.classList.remove('selected');
});
// Select this option
this.classList.add('selected');
myPicks[category] = nominee;
updateProgress();
});
});
updateProgress();
}
function updateProgress() {
const total = categories.length;
const completed = Object.keys(myPicks).length;
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
}
async function submitPicks() {
const picks = Object.keys(myPicks).map(catNum => ({
categoryNumber: parseInt(catNum),
selectedNominee: myPicks[catNum]
}));
if (picks.length === 0) {
showAlert('Veuillez sélectionner au moins un choix', 'warning');
return;
}
if (picks.length < categories.length) {
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
return;
}
}
try {
const res = await fetch('/api/picks/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year: currentYear,
picks
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to submit picks');
}
const data = await res.json();
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
} catch (err) {
console.error('Error submitting picks:', err);
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
}
}
// All picks functionality (admin only)
let allPicks = [];
let allPicksCategories = [];
async function fetchAllPicks() {
try {
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const res = await fetch(`/api/picks/all?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (res.status === 403) {
showAlert('Vous n\'avez pas les privilèges administrateur', 'warning');
return;
}
throw new Error('Failed to fetch picks');
}
const data = await res.json();
allPicks = data.picks || [];
allPicksCategories = data.categories || [];
currentYear = data.year || currentYear;
const yearEl = document.getElementById('all-picks-year');
if (yearEl) yearEl.textContent = currentYear;
const countEl = document.getElementById('users-count');
if (countEl) countEl.textContent = allPicks.length;
renderAllPicks();
} catch (err) {
console.error('Error fetching all picks:', err);
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
}
}
function renderAllPicks() {
const container = document.getElementById('all-picks-container');
if (!container) return;
if (allPicks.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
</div>
`;
return;
}
// Create a map of category info
const categoryMap = new Map();
allPicksCategories.forEach(cat => {
categoryMap.set(cat.categoryNumber, cat);
});
container.innerHTML = allPicks.map(pick => {
const userName = pick.userId?.name || 'Utilisateur inconnu';
const userEmail = pick.userId?.email || '';
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
// Create a map of picks by category
const picksMap = new Map();
pick.picks.forEach(p => {
picksMap.set(p.categoryNumber, p.selectedNominee);
});
// Build picks list
const picksList = allPicksCategories.map(cat => {
const selected = picksMap.get(cat.categoryNumber);
if (!selected) return null;
// Check if it's a winner (if winners are set)
const nominee = cat.nominees.find(n => n.name === selected);
const isWinner = nominee?.isWinner || false;
return `
<div class="pick-item ${isWinner ? 'correct-pick' : ''}">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
<span class="text-muted">${selected}</span>
</div>
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
</div>
</div>
`;
}).filter(Boolean).join('');
return `
<div class="card user-picks-card shadow-sm mb-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">${userName}</h5>
<small class="text-muted">${userEmail}</small>
</div>
<div class="text-end">
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
<span class="badge bg-primary">${pick.picks.length} / ${allPicksCategories.length} catégories</span>
</div>
</div>
</div>
<div class="card-body">
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
</div>
</div>
`;
}).join('');
}
// Event listeners
document.getElementById('submit-picks')?.addEventListener('click', submitPicks);
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
});
document.getElementById('refresh-all-picks')?.addEventListener('click', () => {
fetchAllPicks();
showAlert('Choix rafraîchis', 'info');
});
// Load all picks when admin tab is shown
const allPicksTab = document.getElementById('tab-all-picks');
if (allPicksTab) {
allPicksTab.addEventListener('shown.bs.tab', () => {
fetchAllPicks();
});
}
// Winners & Scores functionality (admin only)
let winnersCategories = [];
async function fetchWinnersCategories() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to fetch categories');
}
const data = await res.json();
winnersCategories = data.categories || [];
currentYear = data.year || currentYear;
const yearEl = document.getElementById('winners-year');
if (yearEl) yearEl.textContent = currentYear;
renderWinnersCategories();
await fetchScores();
} catch (err) {
console.error('Error fetching winners categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
}
}
function renderWinnersCategories() {
const container = document.getElementById('winners-container');
if (!container) return;
if (winnersCategories.length === 0) {
container.innerHTML = '<div class="alert alert-warning">Aucune catégorie trouvée pour cette année.</div>';
return;
}
const winnersCount = winnersCategories.filter(cat =>
cat.nominees.some(n => n.isWinner)
).length;
const progressEl = document.getElementById('winners-progress');
if (progressEl) progressEl.textContent = `${winnersCount} / ${winnersCategories.length}`;
container.innerHTML = winnersCategories.map(cat => {
const winner = cat.nominees.find(n => n.isWinner);
return `
<div class="card category-winner-card shadow-sm mb-3">
<div class="card-header">
<h5 class="mb-0">${cat.categoryNumber}. ${cat.categoryName}</h5>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label fw-semibold">Gagnant:</label>
<select class="form-select winner-select" data-category-id="${cat._id}" data-category-number="${cat.categoryNumber}">
<option value="">-- Sélectionner un gagnant --</option>
${cat.nominees.map(nominee => `
<option value="${nominee.name}" ${nominee.isWinner ? 'selected' : ''}>${nominee.name}</option>
`).join('')}
</select>
</div>
${winner ? `<div class="alert alert-success mb-0 mt-2"><strong>Gagnant actuel:</strong> ${winner.name}</div>` : ''}
</div>
</div>
`;
}).join('');
// Add event listeners for winner selection
container.querySelectorAll('.winner-select').forEach(select => {
select.addEventListener('change', async function() {
const categoryId = this.dataset.categoryId;
const winnerName = this.value;
if (!winnerName) return;
try {
const res = await fetch(`/api/categories/${categoryId}/winner`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ winnerName })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to mark winner');
}
showAlert('Gagnant marqué avec succès', 'success');
await fetchWinnersCategories();
} catch (err) {
console.error('Error marking winner:', err);
showAlert('Erreur: ' + err.message, 'danger');
}
});
});
}
async function calculateScores() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = true;
btn.textContent = 'Calcul en cours...';
}
const res = await fetch('/api/picks/calculate-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ year: currentYear })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to calculate scores');
}
const data = await res.json();
showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
showAlert('Erreur: ' + err.message, 'danger');
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = false;
btn.textContent = 'Calculer les scores';
}
}
}
async function fetchScores() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const res = await fetch(`/api/picks/scores?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 403) {
return; // Not admin, skip
}
throw new Error('Failed to fetch scores');
}
const data = await res.json();
renderScores(data.scores || [], data.totalCategories || 0);
} catch (err) {
console.error('Error fetching scores:', err);
}
}
function renderScores(scores, totalCategories) {
const container = document.getElementById('scores-container');
if (!container) return;
if (scores.length === 0) {
container.innerHTML = '<div class="text-muted">Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.</div>';
return;
}
container.innerHTML = `
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Utilisateur</th>
<th class="text-center">Score</th>
<th class="text-center">Total</th>
<th class="text-center">%</th>
<th class="text-center" style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
${scores.map((score, index) => {
const percentage = totalCategories > 0 ? Math.round((score.score / totalCategories) * 100) : 0;
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
return `
<tr>
<td><strong>${rank}${medal ? ' ' + medal : ''}</strong></td>
<td>${score.userName}${score.userEmail ? ` <small class="text-muted">(${score.userEmail})</small>` : ''}</td>
<td class="text-center"><strong class="text-success">${score.score}</strong></td>
<td class="text-center text-muted">${totalCategories}</td>
<td class="text-center"><strong>${percentage}%</strong></td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
Voir détails
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Store scores data globally for modal access
window.scoresData = scores;
// Add event listeners for view picks buttons
container.querySelectorAll('.view-picks-btn').forEach(btn => {
btn.addEventListener('click', function() {
const scoreIndex = parseInt(this.dataset.scoreIndex);
const score = scores[scoreIndex];
showUserPicksModal(score);
});
});
}
function showUserPicksModal(score) {
const modal = new bootstrap.Modal(document.getElementById('userPicksModal'));
const modalTitle = document.getElementById('userPicksModalLabel');
const modalContent = document.getElementById('user-picks-modal-content');
modalTitle.textContent = `Choix de ${score.userName}`;
if (!score.pickDetails || score.pickDetails.length === 0) {
modalContent.innerHTML = '<div class="text-muted">Aucun détail disponible.</div>';
modal.show();
return;
}
// Separate correct and incorrect picks
const correctPicks = score.pickDetails.filter(p => p.isCorrect);
const incorrectPicks = score.pickDetails.filter(p => !p.isCorrect);
modalContent.innerHTML = `
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-1">Résumé</h6>
<div class="text-muted small">
<span class="text-success"><strong>${correctPicks.length}</strong> correct(s)</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> incorrect(s)</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> total</span>
</div>
</div>
<div class="text-end">
<div class="h4 mb-0 text-success">${score.score} / ${score.totalCategories}</div>
<div class="text-muted small">${Math.round((score.score / score.totalCategories) * 100)}%</div>
</div>
</div>
</div>
${correctPicks.length > 0 ? `
<div class="mb-4">
<h6 class="text-success mb-3">
<span class="badge bg-success me-2">✓</span>
Corrects (${correctPicks.length})
</h6>
<div class="list-group">
${correctPicks.map(pick => `
<div class="list-group-item list-group-item-success">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-1">
<span class="badge bg-success">✓ ${pick.selectedNominee}</span>
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${incorrectPicks.length > 0 ? `
<div class="mb-4">
<h6 class="text-danger mb-3">
<span class="badge bg-danger me-2">✗</span>
Incorrects (${incorrectPicks.length})
</h6>
<div class="list-group">
${incorrectPicks.map(pick => `
<div class="list-group-item list-group-item-danger">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-2">
<div class="mb-1">
<span class="badge bg-danger">✗ Choix: ${pick.selectedNominee}</span>
</div>
${pick.correctWinner ? `
<div>
<span class="badge bg-success">✓ Gagnant: ${pick.correctWinner}</span>
</div>
` : '<div class="text-muted small">Aucun gagnant marqué</div>'}
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
<div class="alert alert-info">
Aucun choix à afficher.
</div>
` : ''}
`;
modal.show();
}
// Event listeners for winners tab
document.getElementById('calculate-scores')?.addEventListener('click', calculateScores);
document.getElementById('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert('Données rafraîchies', 'info');
});
// Load winners when admin tab is shown
const winnersTab = document.getElementById('tab-winners');
if (winnersTab) {
winnersTab.addEventListener('shown.bs.tab', () => {
fetchWinnersCategories();
});
}
// Initial load
(async () => {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
await fetchCategories();
})();
})();
+26 -4
View File
@@ -650,14 +650,24 @@ window.addEventListener('DOMContentLoaded', async function () {
const stats = await statsRes.json(); const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body'); const userTableBody = document.getElementById('user-table-body');
// Ensure watchedRatio is treated as a number and sort in descending order // Sort by total points (descending), then by watched ratio
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio)); stats.sort((a, b) => {
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
if (pointsDiff !== 0) return pointsDiff;
return parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio);
});
// Clear previous rows to prevent duplication // Clear previous rows to prevent duplication
userTableBody.innerHTML = ''; userTableBody.innerHTML = '';
stats.forEach(userStat => { stats.forEach((userStat, index) => {
const userRow = document.createElement('tr'); const userRow = document.createElement('tr');
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
const rankTd = document.createElement('td');
rankTd.innerHTML = `<strong>${rank}${medal ? ' ' + medal : ''}</strong>`;
const nameTd = document.createElement('td'); const nameTd = document.createElement('td');
const link = document.createElement('a'); const link = document.createElement('a');
link.href = '#'; link.href = '#';
@@ -670,13 +680,25 @@ window.addEventListener('DOMContentLoaded', async function () {
nameTd.appendChild(link); nameTd.appendChild(link);
const countTd = document.createElement('td'); const countTd = document.createElement('td');
countTd.textContent = String(userStat.watchedCount ?? ''); const pointsConfig = userStat.pointsConfig || { pointsPerMovie: 1 };
countTd.innerHTML = `${userStat.watchedCount ?? 0} <small class="text-muted">(${userStat.moviePoints || 0} pts)</small>`;
const picksTd = document.createElement('td');
const pickPointsConfig = userStat.pointsConfig || { pointsPerCorrectPick: 1 };
picksTd.innerHTML = `${userStat.correctPicks || 0} <small class="text-muted">(${userStat.pickPoints || 0} pts)</small>`;
const pointsTd = document.createElement('td');
pointsTd.className = 'text-center';
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
const ratioTd = document.createElement('td'); const ratioTd = document.createElement('td');
ratioTd.textContent = String(userStat.watchedRatio ?? ''); ratioTd.textContent = String(userStat.watchedRatio ?? '');
userRow.appendChild(rankTd);
userRow.appendChild(nameTd); userRow.appendChild(nameTd);
userRow.appendChild(countTd); userRow.appendChild(countTd);
userRow.appendChild(picksTd);
userRow.appendChild(pointsTd);
userRow.appendChild(ratioTd); userRow.appendChild(ratioTd);
userTableBody.appendChild(userRow); userTableBody.appendChild(userRow);
}); });
+181
View File
@@ -0,0 +1,181 @@
(function() {
'use strict';
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
const decoded = decodeJwt(token);
if (!decoded) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Check if admin
if (!decoded.admin) {
window.location.href = '/picks.html';
return;
}
// Logout
const logOffBtn = document.getElementById('log-off');
if (logOffBtn) {
logOffBtn.addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
let allPicks = [];
let categories = [];
let currentYear = new Date().getFullYear();
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
async function fetchAllPicks() {
try {
const res = await fetch(`/api/picks/all?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (res.status === 403) {
window.location.href = '/picks.html';
return;
}
throw new Error('Failed to fetch picks');
}
const data = await res.json();
allPicks = data.picks || [];
categories = data.categories || [];
currentYear = data.year || currentYear;
document.getElementById('current-year').textContent = currentYear;
document.getElementById('users-count').textContent = allPicks.length;
renderPicks();
} catch (err) {
console.error('Error fetching picks:', err);
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
}
}
function renderPicks() {
const container = document.getElementById('picks-container');
if (!container) return;
if (allPicks.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
</div>
`;
return;
}
// Create a map of category info
const categoryMap = new Map();
categories.forEach(cat => {
categoryMap.set(cat.categoryNumber, cat);
});
container.innerHTML = allPicks.map(pick => {
const userName = pick.userId?.name || 'Utilisateur inconnu';
const userEmail = pick.userId?.email || '';
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
// Create a map of picks by category
const picksMap = new Map();
pick.picks.forEach(p => {
picksMap.set(p.categoryNumber, p.selectedNominee);
});
// Build picks list
const picksList = categories.map(cat => {
const selected = picksMap.get(cat.categoryNumber);
if (!selected) return null;
// Check if it's a winner (if winners are set)
const nominee = cat.nominees.find(n => n.name === selected);
const isWinner = nominee?.isWinner || false;
return `
<div class="pick-item ${isWinner ? 'correct-pick' : ''}">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
<span class="text-muted">${selected}</span>
</div>
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
</div>
</div>
`;
}).filter(Boolean).join('');
return `
<div class="card user-picks-card shadow-sm">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">${userName}</h5>
<small class="text-muted">${userEmail}</small>
</div>
<div class="text-end">
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
<span class="badge bg-primary">${pick.picks.length} / ${categories.length} catégories</span>
</div>
</div>
</div>
<div class="card-body">
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
</div>
</div>
`;
}).join('');
}
// Event listeners
document.getElementById('refresh-picks')?.addEventListener('click', () => {
fetchAllPicks();
showAlert('Choix rafraîchis', 'info');
});
// Initial load
fetchAllPicks();
})();
+1
View File
@@ -59,6 +59,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a> <a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Mes choix</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.category-card {
margin-bottom: 1.5rem;
}
.nominee-option {
cursor: pointer;
transition: background-color 0.2s;
}
.nominee-option:hover {
background-color: #f8f9fa;
}
.nominee-option.selected {
background-color: #0d6efd;
color: white;
}
.nominee-option.selected:hover {
background-color: #0b5ed7;
}
.user-picks-card {
margin-bottom: 1.5rem;
}
.pick-item {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
}
.pick-item:last-child {
border-bottom: none;
}
.correct-pick {
background-color: #d1e7dd;
}
.incorrect-pick {
background-color: #f8d7da;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
<span id="user-name" class="navbar-text me-3"></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
</div>
</div>
</div>
</nav>
<div class="container mt-5 pb-5">
<h2>Mes choix Oscars</h2>
<div id="alert-container"></div>
<!-- Tabs -->
<ul class="nav nav-tabs mt-3" id="picksTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-my-picks" data-bs-toggle="tab" data-bs-target="#pane-my-picks" type="button" role="tab" aria-controls="pane-my-picks" aria-selected="true">
Mes choix
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link d-none" id="tab-all-picks" data-bs-toggle="tab" data-bs-target="#pane-all-picks" type="button" role="tab" aria-controls="pane-all-picks" aria-selected="false">
Voir tous les choix
<span class="badge bg-warning text-dark ms-1">Admin</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link d-none" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
Gagnants & Scores
<span class="badge bg-warning text-dark ms-1">Admin</span>
</button>
</li>
</ul>
<div class="tab-content pt-3" id="picksTabsContent">
<!-- My Picks Tab -->
<div class="tab-pane fade show active" id="pane-my-picks" role="tabpanel" aria-labelledby="tab-my-picks" tabindex="0">
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold">Année: <span id="current-year">—</span></div>
<div class="text-muted small">Sélectionne tes choix pour chaque catégorie</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="load-my-picks" class="btn btn-outline-secondary">Recharger mes choix</button>
<button type="button" id="submit-picks" class="btn btn-primary">Enregistrer mes choix</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
Progression: <span id="picks-progress">0 / 0</span> catégories complétées
</div>
</div>
</div>
</div>
<div id="categories-container">
<div class="text-center py-4 text-muted">Chargement des catégories…</div>
</div>
</div>
<!-- All Picks Tab (Admin only) -->
<div class="tab-pane fade" id="pane-all-picks" role="tabpanel" aria-labelledby="tab-all-picks" tabindex="0">
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold">Année: <span id="all-picks-year">—</span></div>
<div class="text-muted small">Vue d'ensemble de tous les choix des utilisateurs</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-all-picks" class="btn btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
</div>
</div>
</div>
</div>
<div id="all-picks-container">
<div class="text-center py-4 text-muted">Chargement des choix…</div>
</div>
</div>
<!-- Winners & Scores Tab (Admin only) -->
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold">Année: <span id="winners-year">—</span></div>
<div class="text-muted small">Marquez les gagnants et calculez les scores</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="calculate-scores" class="btn btn-success">Calculer les scores</button>
<button type="button" id="refresh-winners" class="btn btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
<span id="winners-progress">0 / 0</span> catégories avec gagnants marqués
</div>
</div>
</div>
</div>
<div id="winners-container">
<div class="text-center py-4 text-muted">Chargement des catégories…</div>
</div>
<!-- Scores Table -->
<div class="card shadow-sm mt-4">
<div class="card-header">
<h5 class="mb-0">Scores des utilisateurs</h5>
</div>
<div class="card-body">
<div id="scores-container">
<div class="text-center py-4 text-muted">Les scores seront calculés après avoir marqué les gagnants.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for user picks comparison -->
<div class="modal fade" id="userPicksModal" tabindex="-1" aria-labelledby="userPicksModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userPicksModalLabel">Détails des choix</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="user-picks-modal-content">
<div class="text-center py-4 text-muted">Chargement...</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
<script src="./js/app_picks.js"></script>
</body>
</html>
+1
View File
@@ -127,6 +127,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a> <a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div> </div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2"> <div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2">
+5 -1
View File
@@ -65,6 +65,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a> <a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
@@ -108,8 +109,11 @@
<table class="table table-striped table-hover align-middle user-table d-none"> <table class="table table-striped table-hover align-middle user-table d-none">
<thead> <thead>
<tr> <tr>
<th scope="col">#</th>
<th scope="col">Nom</th> <th scope="col">Nom</th>
<th scope="col">Total des films regardés (pts)</th> <th scope="col">Films</th>
<th scope="col">Bonnes réponses</th>
<th scope="col">Points</th>
<th scope="col">Ratio</th> <th scope="col">Ratio</th>
</tr> </tr>
</thead> </thead>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Voir Tous les Choix</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.user-picks-card {
margin-bottom: 1.5rem;
}
.pick-item {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
}
.pick-item:last-child {
border-bottom: none;
}
.correct-pick {
background-color: #d1e7dd;
}
.incorrect-pick {
background-color: #f8d7da;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2">Mes choix</a>
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
<a href="view-picks.html" class="nav-link btn btn-info me-2 active">Voir Tous les Choix</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
<span id="user-name" class="navbar-text me-3"></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
</div>
</div>
</div>
</nav>
<div class="container mt-5 pb-5">
<h2>Voir Tous les Choix</h2>
<div id="alert-container"></div>
<div class="card shadow-sm mt-3 mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold">Année: <span id="current-year">—</span></div>
<div class="text-muted small">Vue d'ensemble de tous les choix des utilisateurs</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-picks" class="btn btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
</div>
</div>
</div>
</div>
<div id="picks-container">
<div class="text-center py-4 text-muted">Chargement des choix…</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
<script src="./js/app_view-picks.js"></script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
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 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 ')
? req.headers['authorization'].split(' ')[1]
: null;
if (!token) {
return res.status(403).json({ message: "No token provided, access denied." });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
req.user = decoded;
next();
});
}
function isAdmin(decoded) {
return !!decoded?.admin;
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Helper function to clean nominee name
function cleanNomineeName(line) {
return line.replace(/^[-☐•]\s*/g, '').replace(/^-\s*☐\s*/, '').trim();
}
// 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+\./)) {
return false;
}
// Skip header/metadata lines
if (skipPatterns.test(line)) {
return false;
}
const nomineeName = cleanNomineeName(line);
if (!nomineeName || nomineeName.length === 0 || nomineeName.length >= 200) {
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)$/) ||
(upperLine.match(/^(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)$/) &&
!nomineeName.includes(',') && nomineeName.length < 30);
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) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { year, categoryNumber, categoryName, nominees } = req.body;
const yearNum = parseOscarYear(year) || new Date().getFullYear();
if (!categoryNumber || !categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryNumber, categoryName, and nominees array are required" });
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
const category = await OscarCategory.findOneAndUpdate(
{ year: yearNum, categoryNumber },
{
year: yearNum,
categoryNumber,
categoryName,
nominees: nomineesArray
},
{ upsert: true, new: true }
);
res.json({ message: "Category saved successfully", category });
} catch (err) {
console.error("Error creating category:", err);
res.status(500).json({ error: err.message });
}
});
// Get all categories for a year (admin view with management)
router.get("/list", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.query.year) || new Date().getFullYear();
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, categories });
} catch (err) {
console.error("Error fetching categories:", err);
res.status(500).json({ error: err.message });
}
});
// Update a category (admin only)
router.put("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { categoryName, nominees } = req.body;
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryName and nominees array are required" });
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
const category = await OscarCategory.findByIdAndUpdate(
req.params.categoryId,
{
categoryName,
nominees: nomineesArray
},
{ new: true }
);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ message: "Category updated successfully", category });
} catch (err) {
console.error("Error updating category:", err);
res.status(500).json({ error: err.message });
}
});
// Mark winner for a category (admin only)
router.put("/:categoryId/winner", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { winnerName } = req.body;
if (!winnerName) {
return res.status(400).json({ message: "winnerName is required" });
}
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
// Mark all nominees as not winners, then mark the selected one as winner
category.nominees = category.nominees.map(n => ({
...n.toObject(),
isWinner: n.name === winnerName
}));
await category.save();
res.json({ message: "Winner marked successfully", category });
} catch (err) {
console.error("Error marking winner:", err);
res.status(500).json({ error: err.message });
}
});
// Delete a category (admin only)
router.delete("/: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.findByIdAndDelete(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ message: "Category deleted successfully" });
} catch (err) {
console.error("Error deleting category:", err);
res.status(500).json({ error: err.message });
}
});
// Ensure uploads directory exists
const uploadsDir = path.join(__dirname, '../uploads/');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
module.exports = router;
+320
View File
@@ -0,0 +1,320 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const OscarCategory = require("../models/OscarCategory");
const OscarPick = require("../models/OscarPick");
const User = require("../models/User");
const Setting = require("../models/Setting");
const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year";
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
return new Date().getFullYear();
}
// Middleware to verify token
function verifyToken(req, res, next) {
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
? req.headers['authorization'].split(' ')[1]
: null;
if (!token) {
return res.status(403).json({ message: "No token provided, access denied." });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
req.user = decoded;
next();
});
}
function isAdmin(decoded) {
return !!decoded?.admin;
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Get all categories for a year (for users to see what to pick)
router.get("/categories", verifyToken, async (req, res) => {
try {
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, categories });
} catch (err) {
console.error("Error fetching categories:", err);
res.status(500).json({ error: err.message });
}
});
// Submit picks (user)
router.post("/submit", verifyToken, async (req, res) => {
try {
const { year, picks } = req.body;
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" });
}
// Verify categories exist for this year
const categories = await OscarCategory.find({ year: yearNum });
if (categories.length === 0) {
return res.status(400).json({ message: "No categories found for this year. Please import categories first." });
}
// Validate picks
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}` });
}
}
// Create or update picks
const pickData = {
userId,
year: yearNum,
picks: picks.map(p => ({
categoryNumber: p.categoryNumber,
categoryName: categoryMap.get(p.categoryNumber).categoryName,
selectedNominee: p.selectedNominee
}))
};
const existingPick = await OscarPick.findOne({ userId, year: yearNum });
if (existingPick) {
existingPick.picks = pickData.picks;
existingPick.submittedAt = new Date();
await existingPick.save();
return res.json({ message: "Picks updated successfully", pick: existingPick });
} else {
const newPick = new OscarPick(pickData);
await newPick.save();
return res.json({ message: "Picks submitted successfully", pick: newPick });
}
} catch (err) {
console.error("Error submitting picks:", err);
res.status(500).json({ error: err.message });
}
});
// Get user's own picks
router.get("/my-picks", verifyToken, async (req, res) => {
try {
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();
if (!pick) {
return res.json({ pick: null, year });
}
res.json({ pick, year });
} catch (err) {
console.error("Error fetching user picks:", err);
res.status(500).json({ error: err.message });
}
});
// Get all picks (admin only) - for viewing all users' picks
router.get("/all", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const picks = await OscarPick.find({ year })
.populate('userId', 'name email')
.sort({ submittedAt: -1 })
.lean();
// Also get categories to show nominee options
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, picks, categories });
} catch (err) {
console.error("Error fetching all picks:", err);
res.status(500).json({ error: err.message });
}
});
// Calculate scores for all users (admin only)
router.post("/calculate-scores", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { year } = req.body;
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
// Get all categories with winners marked
const categories = await OscarCategory.find({ year: yearNum }).lean();
const categoryWinners = new Map();
categories.forEach(cat => {
const winner = cat.nominees.find(n => n.isWinner);
if (winner) {
categoryWinners.set(cat.categoryNumber, winner.name);
}
});
if (categoryWinners.size === 0) {
return res.status(400).json({ message: "No winners marked for any category. Please mark winners first." });
}
// Get all picks for this year with user info
const allPicks = await OscarPick.find({ year: yearNum })
.populate('userId', 'name email')
.lean();
// Calculate scores
const scores = [];
for (const pick of allPicks) {
let correctCount = 0;
const pickDetails = [];
pick.picks.forEach(p => {
const correctWinner = categoryWinners.get(p.categoryNumber);
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
pickDetails.push({
categoryNumber: p.categoryNumber,
categoryName: p.categoryName,
selectedNominee: p.selectedNominee,
correctWinner: correctWinner || null,
isCorrect
});
});
// Update the pick with score
await OscarPick.findByIdAndUpdate(pick._id, { score: correctCount });
scores.push({
userId: pick.userId?._id || pick.userId,
userName: pick.userId?.name || 'Unknown',
userEmail: pick.userId?.email || '',
score: correctCount,
totalCategories: categories.length,
pickDetails
});
}
// Sort by score (descending)
scores.sort((a, b) => b.score - a.score);
res.json({
message: "Scores calculated successfully",
year: yearNum,
scores,
totalCategories: categories.length,
categoriesWithWinners: categoryWinners.size
});
} catch (err) {
console.error("Error calculating scores:", err);
res.status(500).json({ error: err.message });
}
});
// Get scores for all users (admin only)
router.get("/scores", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const picks = await OscarPick.find({ year })
.populate('userId', 'name email')
.sort({ score: -1, submittedAt: 1 })
.lean();
const categories = await OscarCategory.find({ year }).lean();
const categoryWinners = new Map();
categories.forEach(cat => {
const winner = cat.nominees.find(n => n.isWinner);
if (winner) {
categoryWinners.set(cat.categoryNumber, winner.name);
}
});
const scores = picks.map(pick => {
let correctCount = 0;
const pickDetails = [];
// Create a map of all categories for reference
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
pick.picks.forEach(p => {
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) : [];
pickDetails.push({
categoryNumber: p.categoryNumber,
categoryName: p.categoryName,
selectedNominee: p.selectedNominee,
correctWinner: correctWinner || null,
isCorrect,
allNominees: allNominees
});
});
return {
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,
totalCategories: categories.length,
pickDetails,
submittedAt: pick.submittedAt
};
});
scores.sort((a, b) => b.score - a.score);
res.json({ year, scores, totalCategories: categories.length, categoriesWithWinners: categoryWinners.size });
} catch (err) {
console.error("Error fetching scores:", err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+51
View File
@@ -14,6 +14,7 @@ const WINNERS_BY_YEAR_KEY = "winners_by_year";
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year"; const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control"; const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui"; const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const crypto = require("crypto"); const crypto = require("crypto");
@@ -30,6 +31,11 @@ const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
showProgress: true, showProgress: true,
}); });
const DEFAULT_POINTS_CONFIG = Object.freeze({
pointsPerMovie: 1,
pointsPerCorrectPick: 1,
});
function parseOscarYear(raw) { function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null; if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw); const n = Number(raw);
@@ -749,5 +755,50 @@ router.delete("/winners/:year/:userId", async (req, res) => {
} }
}); });
function normalizePointsConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const pointsPerMovie = typeof v.pointsPerMovie === "number" && v.pointsPerMovie >= 0 ? Math.round(v.pointsPerMovie) : DEFAULT_POINTS_CONFIG.pointsPerMovie;
const pointsPerCorrectPick = typeof v.pointsPerCorrectPick === "number" && v.pointsPerCorrectPick >= 0 ? Math.round(v.pointsPerCorrectPick) : DEFAULT_POINTS_CONFIG.pointsPerCorrectPick;
return { pointsPerMovie, pointsPerCorrectPick };
}
// Public: get points configuration
router.get("/points-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value");
const normalized = normalizePointsConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_POINTS_CONFIG);
}
});
// Admin-only: set points configuration
router.put("/points-config", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizePointsConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: POINTS_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePointsConfig(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
module.exports = router; module.exports = router;
+41 -1
View File
@@ -6,6 +6,7 @@ const User = require("../models/User");
const Movie = require("../models/Movie"); const Movie = require("../models/Movie");
const Setting = require("../models/Setting"); const Setting = require("../models/Setting");
const PlaybackProgress = require("../models/PlaybackProgress"); const PlaybackProgress = require("../models/PlaybackProgress");
const OscarPick = require("../models/OscarPick");
const axios = require("axios"); const axios = require("axios");
const router = express.Router(); const router = express.Router();
@@ -142,6 +143,32 @@ router.get("/stats", verifyToken, async (req, res) => {
return res.status(400).json({ message: "Aucun films disponible." }); return res.status(400).json({ message: "Aucun films disponible." });
} }
// Get points configuration
const POINTS_CONFIG_KEY = "points_config";
const pointsConfigSetting = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value").lean();
const pointsConfig = pointsConfigSetting?.value && typeof pointsConfigSetting.value === "object"
? {
pointsPerMovie: typeof pointsConfigSetting.value.pointsPerMovie === "number" ? pointsConfigSetting.value.pointsPerMovie : 1,
pointsPerCorrectPick: typeof pointsConfigSetting.value.pointsPerCorrectPick === "number" ? pointsConfigSetting.value.pointsPerCorrectPick : 1,
}
: { pointsPerMovie: 1, pointsPerCorrectPick: 1 };
// Get all picks for this year with scores
let picksByUserId = new Map();
try {
const allPicks = await OscarPick.find({ year })
.select("userId score")
.lean();
allPicks.forEach(pick => {
const userId = String(pick.userId);
picksByUserId.set(userId, pick.score !== null && pick.score !== undefined ? pick.score : 0);
});
} catch (err) {
console.error("Error fetching picks for stats:", err);
// Continue without picks - users will just have 0 pick points
}
// Build stats for each user // Build stats for each user
const userStats = users.map((user) => { const userStats = users.map((user) => {
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m])); const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
@@ -175,13 +202,26 @@ router.get("/stats", verifyToken, async (req, res) => {
const watchedCount = watchedMoviesDetails.length; const watchedCount = watchedMoviesDetails.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1); const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
// Calculate points
const userId = String(user._id);
const correctPicks = picksByUserId.get(userId) || 0;
const moviePoints = watchedCount * pointsConfig.pointsPerMovie;
const pickPoints = correctPicks * pointsConfig.pointsPerCorrectPick;
const totalPoints = moviePoints + pickPoints;
return { return {
name: user.name, name: user.name,
userId: userId,
year, year,
watchedCount, watchedCount,
totalMoviesCount, totalMoviesCount,
watchedRatio: `${watchedRatio}%`, watchedRatio: `${watchedRatio}%`,
watchedMovies: watchedMoviesDetails // Include movie details watchedMovies: watchedMoviesDetails, // Include movie details
correctPicks: correctPicks,
moviePoints: moviePoints,
pickPoints: pickPoints,
totalPoints: totalPoints,
pointsConfig: pointsConfig
}; };
}); });