Merge 2026 branch into prod #69

Merged
quoije merged 12 commits from 2026 into prod 2025-12-28 01:09:13 -05:00
26 changed files with 3567 additions and 42 deletions
Vendored
BIN
View File
Binary file not shown.
+16
View File
@@ -140,3 +140,19 @@ ayylmao.js
# Python video server - generated TLS material (certbot state)
python_video_server/certs/
# Backup files
backups/
# Video files in public/video/
public/video/*
# Uploads directory (temporary files)
uploads/
# Example files
exemple/
# OCR training data
*.traineddata
eng.traineddata
+33 -18
View File
@@ -1,21 +1,34 @@
## Oscars Pool 2026
Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep track of watched movies for an incoming Oscars Pool with some friends.
**Note:** The application interface is in French.
![Oscars Pool 2026](https://raw.githubusercontent.com/quoije/oscars-pool/refs/heads/prod/img/preview.png)
## Features
- User accounts: register, login, JWT auth
- Optional registration "verification" via `DOG_NAMES` (if unset, verification is skipped)
- Mark nominees as watched (per user), with watched date
- Browse movies by Oscar year + API for available years
- User stats: watched count/ratio and watched titles
- Movie details pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional)
- Admin movie management: add, edit (optionally refresh from OMDb), delete
- Built-in player page with multiple source options (VOD link, direct video URL, embed, or server file)
- Resume playback: per-user playback progress saving (time/duration)
- Global settings: active Oscar year, “completion” modal content for checklist
- Password flows: admin temp password reset + forced change on next login
- App version display (footer) managed from Admin panel (no GitHub dependency)
### User Features
- **User accounts**: register, login, JWT authentication
- **Optional registration verification**: via `DOG_NAMES` environment variable (if unset, verification is skipped)
- **Movie tracking**: mark nominees/movies as watched (per user), with watched date
- **Movie browsing**: browse movies by Oscar year + API for available years
- **Movie details**: pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional)
- **User statistics**: watched count, points from movies watched and Oscar picks, leaderboard
- **Video player**: built-in player page with multiple source options (VOD link, direct video URL, embed, or server file)
- **Resume playback**: per-user playback progress saving (time/duration)
- **Oscar picks**: make picks for each category, auto-save on selection
- **Scores**: view leaderboard with correct/incorrect pick counts, detailed breakdown per user
- **View all picks**: see everyone's picks in a comparison view
### Admin Features
- **Movie management**: add, edit (optionally refresh from OMDb), delete movies
- **User management**: view users, reset passwords (temp password + forced change on next login)
- **Oscar category management**: create, edit, delete categories with nominees, bulk delete
- **Winner management**: mark winners for each category (can clear winners)
- **Global settings**: active Oscar year, "completion" modal content for checklist
- **Database backup/restore**: create backups and restore from backup files
- **App version control**: manage app version display (footer) from admin panel (no GitHub dependency)
- **Player UI settings**: control player page admin status display
## Setup
Create a `.env` file at the project root (same folder as `package.json`).
@@ -52,7 +65,13 @@ This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_UR
- `MONGO_URI` examples:
- Atlas: `mongodb+srv://USER:[email protected]/<dbName>?retryWrites=true&w=majority`
- Local: `mongodb://127.0.0.1:27017/<dbName>`
- Collections are created/managed automatically from the models in `models/` (e.g. `User`, `Movie`, `Setting`, `PlaybackProgress`).
- Collections are created/managed automatically from the models in `models/`:
- `User` - user accounts
- `Movie` - Oscar-nominated movies
- `OscarCategory` - Oscar categories with nominees
- `OscarPick` - user picks for each category
- `Setting` - global settings (active year, etc.)
- `PlaybackProgress` - video playback progress per user
## More `.env` options
The app also reads these environment variables (all optional):
@@ -70,9 +89,5 @@ Notes:
- If you use `video_file` in a movie record, it must be a **relative path** under `VIDEO_FILES_DIR` (the backend blocks traversal/absolute paths).
## Python video server (optional)
There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo).
See `python_video_server/README.md` for full instructions. In addition to `MONGO_URI` and `JWT_SECRET`, you may need:
There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo). See `python_video_server/README.md` for setup and usage instructions.
```bash
export MONGO_DB_NAME="oscars-pool" # if your MONGO_URI doesn't include a /<dbName> path
```
+5 -1
View File
@@ -25,6 +25,8 @@ const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings");
const adminDbRoutes = require("./routes/adminDb");
const videoRoutes = require("./routes/video");
const picksRoutes = require("./routes/picks");
const categoriesRoutes = require("./routes/categories");
app.use(express.static('public'));
app.use("/api/users", userRoutes);
@@ -33,7 +35,9 @@ app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes);
app.use("/api/admin", adminDbRoutes);
app.use("/api/video", videoRoutes);
app.use("/api/picks", picksRoutes);
app.use("/api/categories", categoriesRoutes);
// Start Server
const PORT = process.env.PORT || 5000;
const PORT = process.env.PORT || 5050;
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);
+221 -4
View File
@@ -19,7 +19,8 @@
"jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47",
"mongoose": "^8.9.5",
"multer": "^2.0.2"
"multer": "^2.0.2",
"pdf-parse": "^2.4.5"
},
"devDependencies": {
"nodemon": "^3.1.9"
@@ -34,6 +35,190 @@
"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": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -1432,6 +1617,38 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"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/pdf-parse/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": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1573,9 +1790,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+2 -1
View File
@@ -20,7 +20,8 @@
"jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47",
"mongoose": "^8.9.5",
"multer": "^2.0.2"
"multer": "^2.0.2",
"pdf-parse": "^2.4.5"
},
"devDependencies": {
"nodemon": "^3.1.9"
+2 -1
View File
@@ -61,7 +61,7 @@
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<a class="navbar-brand" style="margin-left: -7px;" href="movies.html">
<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">
@@ -72,6 +72,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
+227 -1
View File
@@ -32,7 +32,7 @@
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<a class="navbar-brand" style="margin-left: -7px;" href="movies.html">
<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">
@@ -43,6 +43,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
@@ -87,6 +88,11 @@
Gagnants
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-categories-tab" data-bs-toggle="tab" data-bs-target="#admin-categories" type="button" role="tab" aria-controls="admin-categories" aria-selected="false">
Catégories Oscars
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
Backup / Restore
@@ -125,6 +131,16 @@
Date Oscars
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-settings-subtab-points" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-points" type="button" role="tab" aria-controls="admin-settings-pane-points" aria-selected="false">
Points
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-settings-subtab-visibility" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-visibility" type="button" role="tab" aria-controls="admin-settings-pane-visibility" aria-selected="false">
Visibilité
</button>
</li>
</ul>
<div class="tab-content pt-3">
@@ -194,6 +210,72 @@
</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>
<!-- Visibility settings -->
<div class="tab-pane fade" id="admin-settings-pane-visibility" role="tabpanel" aria-labelledby="admin-settings-subtab-visibility" tabindex="0">
<div class="border rounded p-3 bg-light">
<div class="text-muted small mb-3">
Contrôle la visibilité de certains éléments dans l'interface.
</div>
<div id="visibility-config-alert" class="alert d-none" role="alert"></div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="visibility-show-picks-features">
<label class="form-check-label" for="visibility-show-picks-features">
Afficher les fonctionnalités "Picks"
</label>
<div class="form-text">Contrôle l'affichage du bouton "Mes picks" dans la navigation et de la colonne "Bon picks" dans les statistiques.</div>
</div>
<div class="mt-3">
<div class="d-flex gap-2">
<button type="button" id="visibility-config-reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="visibility-config-save" class="btn btn-warning">Enregistrer</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -805,6 +887,113 @@
</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">Créez et gérez les catégories 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-manual" data-bs-toggle="tab" data-bs-target="#admin-categories-pane-manual" type="button" role="tab" aria-controls="admin-categories-pane-manual" aria-selected="true">
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">
<!-- Manual creation -->
<div class="tab-pane fade show active" id="admin-categories-pane-manual" role="tabpanel" aria-labelledby="admin-categories-subtab-manual" tabindex="0">
<div class="row g-3">
<div class="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 flex-column gap-2">
<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>
<button type="button" id="bulk-delete-categories-btn" class="btn btn-sm btn-danger" disabled>Supprimer</button>
</div>
<div id="categories-selection-text" class="text-muted small" style="display: none;">
<span id="bulk-delete-count">0</span> catégorie(s) sélectionnée(s)
</div>
</div>
</div>
<div id="categories-list-container" class="border rounded bg-white p-3">
<div class="text-muted">Charge la liste…</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
<div class="card p-3">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
@@ -1032,6 +1221,43 @@
</div>
</div>
<!-- Edit category modal -->
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modifier la catégorie</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit_category_id">
<div class="mb-3">
<label for="edit_category_number" class="form-label">Numéro de catégorie</label>
<input type="number" class="form-control" id="edit_category_number" min="1" max="50" required>
</div>
<div class="mb-3">
<label for="edit_category_name" class="form-label">Nom de la catégorie</label>
<input type="text" class="form-control" id="edit_category_name" required>
</div>
<div class="mb-3">
<label for="edit_nominees_list" class="form-label">Nommés (un par ligne)</label>
<textarea class="form-control" id="edit_nominees_list" rows="10" required></textarea>
<div class="form-text">Entrez un nommé par ligne.</div>
</div>
<div id="edit-category-alert" class="alert d-none" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-primary" id="save-category-changes">Enregistrer</button>
</div>
</div>
</div>
</div>
<!-- Edit movie modal -->
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
+663
View File
@@ -2094,6 +2094,68 @@ window.onload = async function () {
// Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache();
// Categories year dropdowns
const manualYearSelect = document.getElementById('manual-year');
const listYearFilterSelect = document.getElementById('list-year-filter');
// Populate manual creation year dropdown
if (manualYearSelect) {
const previousManualYear = manualYearSelect.value;
manualYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Aucune année (ajoute un film)';
opt.disabled = true;
manualYearSelect.appendChild(opt);
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
manualYearSelect.appendChild(opt);
});
const desired = previousManualYear && cleanedYears.map(String).includes(previousManualYear)
? previousManualYear
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
if (desired) manualYearSelect.value = desired;
}
}
// Populate list filter year dropdown
if (listYearFilterSelect) {
const previousListYear = listYearFilterSelect.value;
listYearFilterSelect.innerHTML = '';
// Add "All years" option
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = 'Toutes les années';
listYearFilterSelect.appendChild(allOpt);
if (cleanedYears.length > 0) {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
listYearFilterSelect.appendChild(opt);
});
// Keep previous selection if still valid
if (previousListYear && cleanedYears.map(String).includes(previousListYear)) {
listYearFilterSelect.value = previousListYear;
} else if (activeYear && cleanedYears.map(String).includes(String(activeYear))) {
listYearFilterSelect.value = String(activeYear);
} else {
listYearFilterSelect.value = '';
}
}
}
} catch (_) {
// ignore
}
@@ -2485,5 +2547,606 @@ window.onload = async function () {
await refreshYears();
await loadMoviesForManagement();
// Points configuration
const pointsPerMovieInput = document.getElementById('points-per-movie');
const pointsPerCorrectPickInput = document.getElementById('points-per-correct-pick');
const pointsConfigReloadBtn = document.getElementById('points-config-reload');
const pointsConfigSaveBtn = document.getElementById('points-config-save');
const pointsConfigAlert = document.getElementById('points-config-alert');
const pointsPerMoviePreview = document.getElementById('points-per-movie-preview');
const pointsPerCorrectPickPreview = document.getElementById('points-per-correct-pick-preview');
async function loadPointsConfig() {
try {
const res = await fetch('/api/settings/points-config', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load points config');
const config = await res.json();
if (pointsPerMovieInput) pointsPerMovieInput.value = config.pointsPerMovie || 1;
if (pointsPerCorrectPickInput) pointsPerCorrectPickInput.value = config.pointsPerCorrectPick || 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = config.pointsPerMovie || 1;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = config.pointsPerCorrectPick || 1;
} catch (err) {
console.error('Error loading points config:', err);
}
}
function updatePointsPreview() {
const moviePoints = pointsPerMovieInput ? parseInt(pointsPerMovieInput.value) || 1 : 1;
const pickPoints = pointsPerCorrectPickInput ? parseInt(pointsPerCorrectPickInput.value) || 1 : 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = moviePoints;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = pickPoints;
}
if (pointsPerMovieInput) {
pointsPerMovieInput.addEventListener('input', updatePointsPreview);
}
if (pointsPerCorrectPickInput) {
pointsPerCorrectPickInput.addEventListener('input', updatePointsPreview);
}
if (pointsConfigReloadBtn) {
pointsConfigReloadBtn.addEventListener('click', async () => {
await loadPointsConfig();
showResponse('success', 'Configuration rechargée');
});
}
if (pointsConfigSaveBtn) {
pointsConfigSaveBtn.addEventListener('click', async () => {
const pointsPerMovie = parseInt(pointsPerMovieInput?.value) || 1;
const pointsPerCorrectPick = parseInt(pointsPerCorrectPickInput?.value) || 1;
if (pointsPerMovie < 0 || pointsPerCorrectPick < 0) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-warning';
pointsConfigAlert.textContent = 'Les points doivent être positifs ou nuls';
pointsConfigAlert.classList.remove('d-none');
}
return;
}
try {
pointsConfigSaveBtn.disabled = true;
const oldText = pointsConfigSaveBtn.textContent;
pointsConfigSaveBtn.textContent = 'Enregistrement...';
const res = await fetch('/api/settings/points-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
pointsPerMovie,
pointsPerCorrectPick
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to save');
}
const data = await res.json();
updatePointsPreview();
showResponse('success', `Configuration enregistrée: ${data.pointsPerMovie} pts/film, ${data.pointsPerCorrectPick} pts/bonne réponse`);
} catch (err) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-danger';
pointsConfigAlert.textContent = 'Erreur: ' + err.message;
pointsConfigAlert.classList.remove('d-none');
}
} finally {
pointsConfigSaveBtn.disabled = false;
pointsConfigSaveBtn.textContent = oldText;
}
});
}
await loadPointsConfig();
// Visibility config
const visibilityShowPicksFeaturesEl = document.getElementById('visibility-show-picks-features');
const visibilityConfigReloadBtn = document.getElementById('visibility-config-reload');
const visibilityConfigSaveBtn = document.getElementById('visibility-config-save');
const visibilityConfigAlert = document.getElementById('visibility-config-alert');
async function loadVisibilityConfig() {
try {
const res = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load visibility config');
const config = await res.json();
// Both settings should be the same - use showPicksButton as the master
const isEnabled = config.showPicksButton !== false && config.showBonPicksColumn !== false;
if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = isEnabled;
} catch (err) {
console.error('Error loading visibility config:', err);
// Default to showing both
if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = true;
}
}
if (visibilityConfigReloadBtn) {
visibilityConfigReloadBtn.addEventListener('click', async () => {
await loadVisibilityConfig();
showResponse('success', 'Configuration rechargée');
});
}
if (visibilityConfigSaveBtn) {
visibilityConfigSaveBtn.addEventListener('click', async () => {
const isEnabled = visibilityShowPicksFeaturesEl ? visibilityShowPicksFeaturesEl.checked : true;
const showPicksButton = isEnabled;
const showBonPicksColumn = isEnabled;
try {
visibilityConfigSaveBtn.disabled = true;
const oldText = visibilityConfigSaveBtn.textContent;
visibilityConfigSaveBtn.textContent = 'Enregistrement...';
const res = await fetch('/api/settings/visibility-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
showPicksButton,
showBonPicksColumn
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to save');
}
const data = await res.json();
// Clear cache so all pages get the new setting immediately
localStorage.removeItem('visibility_config_cache');
showResponse('success', 'Configuration de visibilité enregistrée');
} catch (err) {
if (visibilityConfigAlert) {
visibilityConfigAlert.className = 'alert alert-danger';
visibilityConfigAlert.textContent = 'Erreur: ' + err.message;
visibilityConfigAlert.classList.remove('d-none');
}
} finally {
visibilityConfigSaveBtn.disabled = false;
visibilityConfigSaveBtn.textContent = oldText;
}
});
}
// Load visibility config when visibility tab is shown
const visibilitySubTab = document.getElementById('admin-settings-subtab-visibility');
if (visibilitySubTab) {
visibilitySubTab.addEventListener('shown.bs.tab', async () => {
await loadVisibilityConfig();
});
}
// Categories management
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-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 (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 style="width: 40px;">
<input type="checkbox" class="form-check-input" id="select-all-categories" title="Sélectionner tout">
</th>
<th>#</th>
<th>Nom</th>
<th>Nommés</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${categories.map(cat => `
<tr>
<td>
<input type="checkbox" class="form-check-input category-checkbox" data-category-id="${cat._id}" data-category-name="${cat.categoryName}">
</td>
<td>${cat.categoryNumber}</td>
<td><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>
`;
// Setup bulk selection handlers
setupBulkCategorySelection();
} catch (err) {
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
}
}
window.editCategory = async function(categoryId) {
try {
// Fetch category directly by ID
const res = await fetch(`/api/categories/${categoryId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 404) {
showResponse('danger', 'Catégorie non trouvée');
return;
}
throw new Error('Failed to load category');
}
const data = await res.json();
const category = data.category;
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;
}
const categoryNumber = parseInt(document.getElementById('edit_category_number').value);
if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = 'Le numéro de catégorie doit être entre 1 et 50';
alertEl.classList.remove('d-none');
}
return;
}
// Store button state before async operation
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
try {
saveCategoryChangesBtn.disabled = true;
saveCategoryChangesBtn.textContent = 'Enregistrement...';
// Hide any previous alerts
if (alertEl) {
alertEl.classList.add('d-none');
}
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
categoryNumber,
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) {
// Show error in modal alert
if (alertEl) {
alertEl.className = 'alert alert-danger';
alertEl.textContent = 'Erreur: ' + err.message;
alertEl.classList.remove('d-none');
// Scroll to alert to make it visible
alertEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
// Fallback to main response if alert element not found
showResponse('danger', 'Erreur: ' + err.message);
}
} finally {
// Always restore button state
saveCategoryChangesBtn.disabled = false;
saveCategoryChangesBtn.textContent = oldText;
}
});
}
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();
});
}
// Bulk category selection and deletion
function setupBulkCategorySelection() {
const selectAllCheckbox = document.getElementById('select-all-categories');
const bulkDeleteBtn = document.getElementById('bulk-delete-categories-btn');
const bulkDeleteCount = document.getElementById('bulk-delete-count');
const selectionText = document.getElementById('categories-selection-text');
const categoryCheckboxes = document.querySelectorAll('.category-checkbox');
if (!selectAllCheckbox || !bulkDeleteBtn) return;
// Select all checkbox handler
selectAllCheckbox.addEventListener('change', function() {
categoryCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkDeleteButton();
});
// Individual checkbox handlers
categoryCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateBulkDeleteButton();
// Update select all checkbox state
const allChecked = Array.from(categoryCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(categoryCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = someChecked && !allChecked;
});
});
// Update bulk delete button state
function updateBulkDeleteButton() {
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
const count = selected.length;
if (bulkDeleteCount) {
bulkDeleteCount.textContent = count;
}
bulkDeleteBtn.disabled = count === 0;
if (selectionText) {
selectionText.style.display = count > 0 ? 'block' : 'none';
}
}
// Bulk delete handler
bulkDeleteBtn.addEventListener('click', async function() {
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
if (selected.length === 0) return;
const categoryIds = selected.map(cb => cb.dataset.categoryId);
const categoryNames = selected.map(cb => cb.dataset.categoryName);
const confirmMessage = `Êtes-vous sûr de vouloir supprimer ${categoryIds.length} catégorie(s)?\n\n${categoryNames.join('\n')}\n\nCette action est irréversible.`;
if (!confirm(confirmMessage)) return;
try {
bulkDeleteBtn.disabled = true;
const oldText = bulkDeleteBtn.textContent;
bulkDeleteBtn.textContent = 'Suppression...';
// Delete categories one by one
let successCount = 0;
let errorCount = 0;
for (const categoryId of categoryIds) {
try {
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
successCount++;
} else {
errorCount++;
}
} catch (err) {
console.error(`Error deleting category ${categoryId}:`, err);
errorCount++;
}
}
if (successCount > 0) {
showResponse('success', `${successCount} catégorie(s) supprimée(s)${errorCount > 0 ? `, ${errorCount} erreur(s)` : ''}`);
await loadCategoriesList();
} else {
showResponse('danger', `Erreur lors de la suppression: ${errorCount} erreur(s)`);
}
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
} finally {
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.textContent = oldText;
}
});
// Initialize button state
updateBulkDeleteButton();
}
};
+54
View File
@@ -50,5 +50,59 @@
if (adminLink && decoded.admin) {
adminLink.classList.remove('d-none');
}
// Check visibility config for picks button
const picksButton = document.getElementById('nav-link-picks');
if (picksButton) {
// Check cached value first for immediate display
const cachedConfig = localStorage.getItem('visibility_config_cache');
let cachedValue = null;
let cachedTimestamp = null;
if (cachedConfig) {
try {
const parsed = JSON.parse(cachedConfig);
cachedValue = parsed.value;
cachedTimestamp = parsed.timestamp;
} catch (e) {
// Invalid cache, ignore
}
}
// Use cached value if it's less than 5 minutes old
const cacheAge = cachedTimestamp ? Date.now() - cachedTimestamp : Infinity;
const useCache = cachedValue !== null && cacheAge < 5 * 60 * 1000;
if (useCache) {
// Show button immediately based on cached value
if (cachedValue.showPicksButton !== false) {
picksButton.style.display = '';
}
} else {
// Default to showing while we fetch
picksButton.style.display = '';
}
// Fetch fresh value from server
fetch('/api/settings/visibility-config', { cache: 'no-store' })
.then(res => res.json())
.then(config => {
// Cache the result
localStorage.setItem('visibility_config_cache', JSON.stringify({
value: config,
timestamp: Date.now()
}));
// Update display based on fresh value
if (config.showPicksButton !== false) {
picksButton.style.display = '';
} else {
picksButton.style.display = 'none';
}
})
.catch(() => {
// On error, keep current state (already set from cache or default)
});
}
})();
+765
View File
@@ -0,0 +1,765 @@
(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 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;
let isSaving = false; // Prevent multiple simultaneous saves
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 picks`;
}
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 (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 = {};
// Create a map of existing category numbers
const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber));
// Only load picks for categories that still exist
data.pick.picks.forEach(p => {
if (existingCategoryNumbers.has(p.categoryNumber)) {
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 = `
<div class="table-responsive">
<table class="table table-sm picks-table align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Choix</th>
<th>Nommés</th>
</tr>
</thead>
<tbody>
${categories.map(cat => {
const selectedNominee = myPicks[cat.categoryNumber];
return `
<tr class="${selectedNominee ? 'has-selection' : ''}">
<td class="fw-semibold text-muted">${cat.categoryNumber}</td>
<td class="fw-semibold">${cat.categoryName}</td>
<td>
${selectedNominee ? `
<span class="selected-choice-badge badge bg-success">
<span>${selectedNominee}</span>
</span>
` : '<span class="text-muted fst-italic">Aucun choix</span>'}
</td>
<td>
<div class="d-flex flex-wrap gap-2">
${cat.nominees.map(nominee => {
const isSelected = selectedNominee === nominee.name;
return `
<span class="nominee-option badge ${isSelected ? 'bg-primary selected' : 'bg-secondary bg-opacity-50'}"
data-category="${cat.categoryNumber}"
data-nominee="${nominee.name}">
${isSelected ? '<span class="nominee-checkmark">✓</span>' : ''}
<span>${nominee.name}</span>
</span>
`;
}).join('')}
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Add click handlers
container.querySelectorAll('.nominee-option').forEach(el => {
el.addEventListener('click', function() {
const category = parseInt(this.dataset.category);
const nominee = this.dataset.nominee;
// Toggle selection
if (myPicks[category] === nominee) {
// Deselect if clicking the same nominee
delete myPicks[category];
} else {
// Select this nominee
myPicks[category] = nominee;
}
// Re-render to update the display
renderCategories();
// Auto-save after selection change
autoSavePicks();
});
});
updateProgress();
}
async function autoSavePicks() {
// Clear any existing timeout to reset the debounce
if (autoSavePicks.timeout) {
clearTimeout(autoSavePicks.timeout);
}
// Debounce: wait for user to stop clicking before saving
autoSavePicks.timeout = setTimeout(async () => {
// Skip if already saving (will retry after current save completes)
if (isSaving) {
// Retry after a short delay
setTimeout(() => autoSavePicks(), 200);
return;
}
isSaving = true;
try {
await submitPicks(true); // Pass true for silent mode (no alert)
} catch (err) {
console.error('Auto-save failed:', err);
// Show error alert only on failure
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
} finally {
isSaving = false;
}
}, 250); // Reduced to 250ms for better responsiveness
}
function updateProgress() {
const total = categories.length;
const completed = Object.keys(myPicks).length;
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
}
async function submitPicks(silent = false) {
const picks = Object.keys(myPicks).map(catNum => ({
categoryNumber: parseInt(catNum),
selectedNominee: myPicks[catNum]
}));
// Allow empty picks to be saved (to clear all selections)
// Only show warning in non-silent mode if user explicitly tries to submit with no picks
if (picks.length === 0 && !silent) {
// Don't block - allow saving empty picks to clear selections
}
// Skip confirmation dialog in silent mode (auto-save)
if (!silent && picks.length < categories.length) {
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
return;
}
}
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();
if (!silent) {
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
}
} catch (err) {
console.error('Error submitting picks:', err);
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
}
}
// Event listeners
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
});
// 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 = `
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Sélection</th>
<th>Gagnant</th>
</tr>
</thead>
<tbody>
${winnersCategories.map(cat => {
const winner = cat.nominees.find(n => n.isWinner);
return `
<tr class="${winner ? 'table-success' : ''}">
<td class="fw-semibold">${cat.categoryNumber}</td>
<td>${cat.categoryName}</td>
<td>
<select class="form-select form-select-sm winner-select" data-category-id="${cat._id}" data-category-number="${cat.categoryNumber}">
<option value="">—</option>
${cat.nominees.map(nominee => `
<option value="${nominee.name}" ${nominee.isWinner ? 'selected' : ''}>${nominee.name}</option>
`).join('')}
</select>
</td>
<td>
${winner ? `<span class="badge bg-success">${winner.name}</span>` : '<span class="text-muted">—</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// 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;
try {
// If empty value, clear the winner (set to null/empty)
const res = await fetch(`/api/categories/${categoryId}/winner`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ winnerName: winnerName || null })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to update winner');
}
showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', 'success');
await fetchWinnersCategories();
// Auto-refresh scores after marking/clearing a winner (silent mode)
await calculateScores(true);
} catch (err) {
console.error('Error updating winner:', err);
showAlert('Erreur: ' + err.message, 'danger');
}
});
});
}
async function calculateScores(silent = false) {
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();
// If no winners marked, handle gracefully (especially for auto-refresh)
if (error.message && error.message.includes('No winners marked')) {
if (!silent) {
// Only show as info/warning, not error
showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info');
}
// Still try to fetch existing scores
await fetchScores();
return;
}
throw new Error(error.message || 'Failed to calculate scores');
}
const data = await res.json();
if (!silent) {
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);
if (!silent) {
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();
}
// Update scores year display
const scoresYearEl = document.getElementById('scores-year');
if (scoresYearEl) {
scoresYearEl.textContent = currentYear;
}
const res = await fetch(`/api/picks/scores?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ message: 'Failed to fetch scores' }));
throw new Error(errorData.message || 'Failed to fetch scores');
}
const data = await res.json();
renderScores(data.scores || [], data.totalCategories || 0);
} catch (err) {
console.error('Error fetching scores:', err);
const container = document.getElementById('scores-container');
if (container) {
container.innerHTML = '<div class="text-danger">Erreur lors du chargement des scores.</div>';
}
}
}
function renderScores(scores, totalCategories) {
const container = document.getElementById('scores-container');
if (!container) return;
// Filter out scores for removed users (Unknown users or null userId)
let validScores = scores.filter(score =>
score.userName &&
score.userName !== 'Unknown' &&
score.userId
);
// Calculate actual scores and sort by them
validScores = validScores.map(score => {
// Always calculate actual score from pickDetails if available (more accurate than stored score)
let actualScore;
if (score.pickDetails && Array.isArray(score.pickDetails) && score.pickDetails.length > 0) {
// Calculate from pickDetails - this is the source of truth
actualScore = score.pickDetails.filter(p => p.isCorrect === true).length;
} else {
// Fallback to stored score only if pickDetails not available
actualScore = score.score || 0;
}
return { ...score, actualScore };
}).sort((a, b) => b.actualScore - a.actualScore);
if (validScores.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-sm table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>Utilisateur</th>
<th class="text-center" style="width: 80px;">Score</th>
<th class="text-center" style="width: 70px;">Total</th>
<th class="text-center" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
${validScores.map((score, index) => {
// Use the pre-calculated actualScore (always use it, even if 0)
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
return `
<tr>
<td><strong>${rank}${medal ? ' ' + medal : ''}</strong></td>
<td>${score.userName}</td>
<td class="text-center"><strong class="text-success">${actualScore}</strong></td>
<td class="text-center text-muted small">${totalCategories}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
Détails
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Store valid scores data globally for modal access
window.scoresData = validScores;
// 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 = validScores[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);
// Calculate actual score from pickDetails (more accurate than stored score)
const actualScore = correctPicks.length;
const totalCategories = score.totalCategories || score.pickDetails.length;
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">${actualScore} / ${totalCategories}</div>
<div class="text-muted small">${totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0}%</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('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert('Données rafraîchies', 'info');
});
// Load scores when scores tab is shown
const scoresTab = document.getElementById('tab-scores');
if (scoresTab) {
scoresTab.addEventListener('shown.bs.tab', async () => {
await fetchScores();
});
}
// Load winners when admin tab is shown
const winnersTab = document.getElementById('tab-winners');
if (winnersTab) {
winnersTab.addEventListener('shown.bs.tab', async () => {
await fetchWinnersCategories();
// Auto-refresh scores when entering the winners tab (silent mode)
await calculateScores(true);
});
}
// Refresh scores button
document.getElementById('refresh-scores')?.addEventListener('click', async () => {
await fetchScores();
showAlert('Scores rafraîchis', 'info');
});
// Initial load
(async () => {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
await fetchCategories();
// Auto-refresh scores on page load if scores tab is active
const scoresTab = document.getElementById('tab-scores');
const scoresPane = document.getElementById('pane-scores');
if (scoresTab && scoresPane && (scoresTab.classList.contains('active') || scoresPane.classList.contains('active'))) {
await fetchScores();
}
// Auto-refresh winners on page load if admin and winners tab is active
if (decoded.admin) {
const winnersTab = document.getElementById('tab-winners');
const winnersPane = document.getElementById('pane-winners');
// Check if winners tab is active (either by default or if it becomes active)
if (winnersTab && winnersPane && (winnersTab.classList.contains('active') || winnersPane.classList.contains('active'))) {
await fetchWinnersCategories();
await calculateScores(true); // Silent mode for auto-refresh
}
}
})();
})();
+53 -7
View File
@@ -650,14 +650,49 @@ window.addEventListener('DOMContentLoaded', async function () {
const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body');
// Ensure watchedRatio is treated as a number and sort in descending order
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
// Check visibility config for Bon picks column
let showBonPicksColumn = true;
try {
const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (visibilityRes.ok) {
const visibilityConfig = await visibilityRes.json();
showBonPicksColumn = visibilityConfig.showBonPicksColumn !== false;
}
} catch (err) {
console.error('Error loading visibility config:', err);
}
// Hide/show Bon picks header
const bonPicksHeader = document.getElementById('bon-picks-header');
if (bonPicksHeader) {
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
}
// Sort by total points (descending)
stats.sort((a, b) => {
return (b.totalPoints || 0) - (a.totalPoints || 0);
});
// Clear previous rows to prevent duplication
userTableBody.innerHTML = '';
stats.forEach(userStat => {
// Get total movies count (same for all users in the same year)
const totalMovies = stats.length > 0 ? (stats[0].totalMoviesCount || 0) : 0;
// Update Films column header with total
const filmsHeader = document.getElementById('films-header');
if (filmsHeader && totalMovies > 0) {
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
}
stats.forEach((userStat, index) => {
const userRow = document.createElement('tr');
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
const rankTd = document.createElement('td');
rankTd.innerHTML = `<strong>${rank}${medal ? ' ' + medal : ''}</strong>`;
const nameTd = document.createElement('td');
const link = document.createElement('a');
link.href = '#';
@@ -670,14 +705,25 @@ window.addEventListener('DOMContentLoaded', async function () {
nameTd.appendChild(link);
const countTd = document.createElement('td');
countTd.textContent = String(userStat.watchedCount ?? '');
const pointsConfig = userStat.pointsConfig || { pointsPerMovie: 1 };
countTd.innerHTML = `${userStat.watchedCount ?? 0} <small class="text-muted">(${userStat.moviePoints || 0} pts)</small>`;
const ratioTd = document.createElement('td');
ratioTd.textContent = String(userStat.watchedRatio ?? '');
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>`;
if (!showBonPicksColumn) {
picksTd.style.display = 'none';
}
const pointsTd = document.createElement('td');
pointsTd.className = 'text-center';
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
userRow.appendChild(rankTd);
userRow.appendChild(nameTd);
userRow.appendChild(countTd);
userRow.appendChild(ratioTd);
userRow.appendChild(picksTd);
userRow.appendChild(pointsTd);
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();
})();
+2 -1
View File
@@ -48,7 +48,7 @@
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<a class="navbar-brand" style="margin-left: -7px;" href="movies.html">
<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">
@@ -59,6 +59,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
+239
View File
@@ -0,0 +1,239 @@
<!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 picks</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");
}
.nominee-option {
cursor: pointer;
transition: all 0.2s;
position: relative;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.nominee-option:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.nominee-option.selected {
font-weight: 600;
box-shadow: 0 2px 6px rgba(13, 110, 253, 0.3);
}
.nominee-checkmark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
font-size: 0.75rem;
font-weight: bold;
}
.nominee-option.selected .nominee-checkmark {
background-color: rgba(255, 255, 255, 0.9);
color: #0d6efd;
}
.picks-table {
border-collapse: separate;
border-spacing: 0;
}
.picks-table tbody tr {
transition: background-color 0.2s;
}
.picks-table tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
.picks-table tbody tr.has-selection {
background-color: rgba(13, 110, 253, 0.08);
border-left: 3px solid #0d6efd;
}
.selected-choice-badge {
font-weight: 600;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.table-sm td {
padding: 0.5rem;
vertical-align: middle;
}
.table-sm th {
padding: 0.5rem;
font-size: 0.875rem;
}
.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;" href="movies.html">
<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" id="nav-link-picks" style="display: none;">Mes picks</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 picks 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 picks
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-scores" data-bs-toggle="tab" data-bs-target="#pane-scores" type="button" role="tab" aria-controls="pane-scores" aria-selected="false">
Scores
</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
<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="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="current-year">—</span></span>
<span class="text-muted small ms-2">Progression: <span id="picks-progress">0 / 0</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
</div>
</div>
<div id="categories-container">
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
</div>
</div>
<!-- Scores Tab -->
<div class="tab-pane fade" id="pane-scores" role="tabpanel" aria-labelledby="tab-scores" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="scores-year">—</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-scores" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div id="scores-container">
<div class="text-center py-3 text-muted small">Les scores seront calculés après avoir marqué les gagnants.</div>
</div>
</div>
<!-- Gagnants Tab (Admin only) -->
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="winners-year">—</span></span>
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> gagnants marqués</span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div id="winners-container">
<div class="text-center py-3 text-muted small">Chargement des catégories…</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="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" id="nav-link-picks" style="display: none;">Mes picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2">
+6 -3
View File
@@ -54,7 +54,7 @@
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<a class="navbar-brand" style="margin-left: -7px;" href="movies.html">
<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">
@@ -65,6 +65,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
@@ -108,9 +109,11 @@
<table class="table table-striped table-hover align-middle user-table d-none">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Nom</th>
<th scope="col">Total des films regardés (pts)</th>
<th scope="col">Ratio</th>
<th scope="col" id="films-header">Films</th>
<th scope="col" id="bon-picks-header">Bon picks</th>
<th scope="col" class="text-center">Points</th>
</tr>
</thead>
<tbody id="user-table-body">
Binary file not shown.
+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;" href="movies.html">
<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" id="nav-link-picks" style="display: none;">Mes picks</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>
+28 -2
View File
@@ -168,10 +168,36 @@ sudo -E python -m python_video_server.letsencrypt renew --production
## Endpoints
- `POST /api/video/session`
Sets `video_auth` cookie (send `Authorization: Bearer <token>`).
Sets `video_auth` cookie (send `Authorization: Bearer <token>`). Returns `204 No Content`.
- `GET|HEAD /api/video/{movieId}`
Streams the movie file (supports `Range: bytes=...`).
Streams the movie file (supports `Range: bytes=...`). Returns `200` (full file) or `206` (partial content) or `404` (not found).
- `GET /healthz`
Health check endpoint. Returns JSON with server status, video files directory, MongoDB database name, and OpenSSL version.
## Functions Reference
### Public Endpoints
- `create_video_session(request, response, authorization)` - POST `/api/video/session` - Creates video authentication cookie
- `stream_video(movie_id, request, authorization, range)` - GET|HEAD `/api/video/{movie_id}` - Streams video file with Range support
- `healthz()` - GET `/healthz` - Health check endpoint
### Helper Functions (Internal)
- `_env_str(name, default)` - Get environment variable as string
- `_jwt_secret()` - Get JWT secret from environment
- `_mongo_uri()` - Get MongoDB connection URI from environment
- `_mongo_db_name()` - Get MongoDB database name (from env or parsed from URI)
- `_env_int(name, default)` - Get environment variable as integer
- `_video_files_dir()` - Get video files directory path (default: `./public/video`)
- `_parse_bearer(authorization)` - Parse Bearer token from Authorization header
- `_is_safe_relative_file(path)` - Security check: validates file path is safe (no directory traversal)
- `_guess_content_type(file_path)` - Guess MIME content type from file extension
- `_parse_range(range_header, file_size)` - Parse HTTP Range header into start/end tuple
- `_file_iter(path, start, end, chunk_size)` - Generator that yields file chunks for streaming
- `_get_token(request, authorization)` - Get authentication token from cookie, header, or query string (priority order)
- `_verify_token(token)` - Verify JWT token and return decoded payload
- `_mongo_client()` - Create MongoDB client with TLS/connection timeout configuration
- `_mongo_client_cached()` - Cached MongoDB client (LRU cache, reused across requests)
- `_shutdown()` - Event handler for graceful MongoDB client shutdown
+374
View File
@@ -0,0 +1,374 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const path = require("path");
const fs = require("fs");
const OscarCategory = require("../models/OscarCategory");
const OscarPick = require("../models/OscarPick");
const router = express.Router();
// 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 (number first or name first with number)
if (line.match(/^(\d+)[\t\s]+(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)/i) ||
line.match(/^(.+?)[\t\s]+\d+$/) ||
line.match(/^\d+\./)) {
return false;
}
// Skip header/metadata lines
if (skipPatterns.test(line)) {
return false;
}
const nomineeName = cleanNomineeName(line);
if (!nomineeName || nomineeName.length === 0 || nomineeName.length >= 200) {
return false;
}
// Skip lines that are just numbers or dashes
if (nomineeName.match(/^-?\s*\d+\s*-?$/) || nomineeName.match(/^-+\s*$/)) {
return false;
}
const upperLine = nomineeName.toUpperCase();
// Skip if it looks like a header (but allow category names that might appear in nominees with commas)
const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) ||
(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;
}
// 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 });
}
});
// Get a single category by ID (admin only) - must come after /list route
router.get("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const category = await OscarCategory.findById(req.params.categoryId).lean();
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ category });
} catch (err) {
console.error("Error fetching category:", err);
res.status(500).json({ error: err.message });
}
});
// Update a category (admin only)
router.put("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { categoryNumber, categoryName, nominees } = req.body;
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryName and nominees array are required" });
}
if (categoryNumber !== undefined && categoryNumber !== null) {
const categoryNum = parseInt(categoryNumber);
if (!Number.isInteger(categoryNum) || categoryNum < 1 || categoryNum > 50) {
return res.status(400).json({ message: "categoryNumber must be between 1 and 50" });
}
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
// Get the current category to check year and handle categoryNumber conflicts
const currentCategory = await OscarCategory.findById(req.params.categoryId);
if (!currentCategory) {
return res.status(404).json({ message: "Category not found" });
}
const year = currentCategory.year;
const updateData = {
categoryName,
nominees: nomineesArray
};
// If categoryNumber is being changed, check for conflicts and update existing picks
if (categoryNumber !== undefined && categoryNumber !== null) {
const newCategoryNum = parseInt(categoryNumber);
if (newCategoryNum !== currentCategory.categoryNumber) {
const oldCategoryNum = currentCategory.categoryNumber;
// Check if another category with this number already exists for this year
const existingCategory = await OscarCategory.findOne({
year,
categoryNumber: newCategoryNum,
_id: { $ne: req.params.categoryId }
});
if (existingCategory) {
return res.status(400).json({
message: `Category number ${newCategoryNum} already exists for year ${year}`
});
}
// Update all existing picks that reference the old category number
// Match by old categoryNumber and old categoryName to ensure we're updating the right picks
const picksToUpdate = await OscarPick.find({ year });
let updatedPicksCount = 0;
for (const pick of picksToUpdate) {
if (pick.picks && Array.isArray(pick.picks)) {
let needsUpdate = false;
const updatedPicks = pick.picks.map(p => {
// Match by both old categoryNumber and old categoryName to be safe
if (p.categoryNumber === oldCategoryNum && p.categoryName === currentCategory.categoryName) {
needsUpdate = true;
return {
...p,
categoryNumber: newCategoryNum,
categoryName: categoryName // Update categoryName too if it changed
};
}
return p;
});
if (needsUpdate) {
pick.picks = updatedPicks;
await pick.save();
updatedPicksCount++;
}
}
}
updateData.categoryNumber = newCategoryNum;
// Log the update (optional, for debugging)
if (updatedPicksCount > 0) {
console.log(`Updated ${updatedPicksCount} pick(s) when changing category number from ${oldCategoryNum} to ${newCategoryNum}`);
}
}
}
const category = await OscarCategory.findByIdAndUpdate(
req.params.categoryId,
updateData,
{ 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;
const isClearing = !winnerName || winnerName === '' || winnerName === null;
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
if (isClearing) {
// Clear all winners (set all nominees to not winners)
category.nominees = category.nominees.map(n => ({
...n.toObject(),
isWinner: false
}));
await category.save();
return res.json({ message: "Winner cleared successfully", category });
}
// Mark all nominees as not winners, then mark the selected one as winner
category.nominees = category.nominees.map(n => ({
...n.toObject(),
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 });
}
});
// Helper function to clean up picks for deleted categories
async function cleanupPicksForDeletedCategories(year) {
try {
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const allPicks = await OscarPick.find({ year });
let totalCleaned = 0;
for (const pick of allPicks) {
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
if (pick.picks.length !== originalLength) {
await pick.save();
totalCleaned += (originalLength - pick.picks.length);
}
}
}
if (totalCleaned > 0) {
console.log(`Cleaned up ${totalCleaned} pick(s) for deleted categories in year ${year}`);
}
return totalCleaned;
} catch (err) {
console.error("Error cleaning up picks:", err);
throw err;
}
}
// Delete a category (admin only)
router.delete("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
const year = category.year;
// Delete the category
await OscarCategory.findByIdAndDelete(req.params.categoryId);
// Clean up picks for deleted categories
await cleanupPicksForDeletedCategories(year);
res.json({ message: "Category deleted successfully" });
} catch (err) {
console.error("Error deleting category:", err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+381
View File
@@ -0,0 +1,381 @@
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)) {
return res.status(400).json({ message: "Picks must be an array" });
}
// 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." });
}
// Filter and validate picks - remove picks for deleted categories
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const validPicks = [];
if (picks.length > 0) {
for (const pick of picks) {
if (!pick.categoryNumber || !pick.selectedNominee) {
continue; // Skip invalid picks
}
const category = categoryMap.get(pick.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
continue;
}
const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
if (!nomineeExists) {
// Nominee doesn't exist in this category - skip this pick
continue;
}
validPicks.push({
categoryNumber: pick.categoryNumber,
categoryName: category.categoryName,
selectedNominee: pick.selectedNominee
});
}
}
// Create or update picks (only with valid picks)
const pickData = {
userId,
year: yearNum,
picks: validPicks
};
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 });
if (!pick) {
return res.json({ pick: null, year });
}
// Clean up picks for deleted categories
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
// If picks were cleaned up, save the updated pick
if (pick.picks.length !== originalLength) {
await pick.save();
}
}
res.json({ pick: pick.toObject(), year });
} catch (err) {
console.error("Error fetching user picks:", err);
res.status(500).json({ error: err.message });
}
});
// 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();
// Create a map of all categories for reference
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
// Calculate scores
const scores = [];
for (const pick of allPicks) {
let correctCount = 0;
const pickDetails = [];
// Only process picks for categories that still exist
pick.picks.forEach(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
return;
}
const correctWinner = categoryWinners.get(p.categoryNumber);
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
pickDetails.push({
categoryNumber: p.categoryNumber,
categoryName: p.categoryName,
selectedNominee: p.selectedNominee,
correctWinner: correctWinner || null,
isCorrect
});
});
// Update the pick with score and clean up deleted category picks
const validPicks = pick.picks.filter(p => categoryMap.has(p.categoryNumber));
await OscarPick.findByIdAndUpdate(pick._id, {
score: correctCount,
picks: validPicks
});
scores.push({
userId: pick.userId?._id || pick.userId,
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 (available to all authenticated users)
router.get("/scores", verifyToken, async (req, res) => {
try {
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
// Clean up picks for deleted categories before fetching scores
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const picksToClean = await OscarPick.find({ year });
for (const pick of picksToClean) {
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
if (pick.picks.length !== originalLength) {
await pick.save();
}
}
}
const picks = await OscarPick.find({ year })
.populate('userId', 'name email')
.sort({ score: -1, submittedAt: 1 })
.lean();
// Reuse categories already fetched for cleanup
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]));
// Only process picks for categories that still exist
pick.picks.forEach(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
return;
}
const correctWinner = categoryWinners.get(p.categoryNumber);
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
const allNominees = 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: correctCount, // Always use the calculated score from existing categories
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;
+102
View File
@@ -14,6 +14,8 @@ const WINNERS_BY_YEAR_KEY = "winners_by_year";
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const VISIBILITY_CONFIG_KEY = "visibility_config";
const crypto = require("crypto");
@@ -30,6 +32,16 @@ const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
showProgress: true,
});
const DEFAULT_POINTS_CONFIG = Object.freeze({
pointsPerMovie: 1,
pointsPerCorrectPick: 1,
});
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
showPicksButton: true,
showBonPicksColumn: true,
});
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
@@ -749,5 +761,95 @@ 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 };
}
function normalizeVisibilityConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
return { showPicksButton, showBonPicksColumn };
}
// 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 });
}
});
// Public: get visibility configuration
router.get("/visibility-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: VISIBILITY_CONFIG_KEY }).select("value");
const normalized = normalizeVisibilityConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_VISIBILITY_CONFIG);
}
});
// Admin-only: set visibility configuration
router.put("/visibility-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 = normalizeVisibilityConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: VISIBILITY_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeVisibilityConfig(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;
+64 -1
View File
@@ -6,6 +6,8 @@ const User = require("../models/User");
const Movie = require("../models/Movie");
const Setting = require("../models/Setting");
const PlaybackProgress = require("../models/PlaybackProgress");
const OscarPick = require("../models/OscarPick");
const OscarCategory = require("../models/OscarCategory");
const axios = require("axios");
const router = express.Router();
@@ -142,6 +144,54 @@ router.get("/stats", verifyToken, async (req, res) => {
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 categories with winners marked for this year
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);
}
});
// Get all picks for this year and calculate correct picks on-the-fly
let picksByUserId = new Map();
try {
const allPicks = await OscarPick.find({ year })
.select("userId picks")
.lean();
allPicks.forEach(pick => {
const userId = String(pick.userId);
let correctCount = 0;
// Calculate correct picks based on current winners
if (Array.isArray(pick.picks)) {
pick.picks.forEach(p => {
const correctWinner = categoryWinners.get(p.categoryNumber);
if (correctWinner && p.selectedNominee === correctWinner) {
correctCount++;
}
});
}
picksByUserId.set(userId, correctCount);
});
} 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
const userStats = users.map((user) => {
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
@@ -175,13 +225,26 @@ router.get("/stats", verifyToken, async (req, res) => {
const watchedCount = watchedMoviesDetails.length;
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 {
name: user.name,
userId: userId,
year,
watchedCount,
totalMoviesCount,
watchedRatio: `${watchedRatio}%`,
watchedMovies: watchedMoviesDetails // Include movie details
watchedMovies: watchedMoviesDetails, // Include movie details
correctPicks: correctPicks,
moviePoints: moviePoints,
pickPoints: pickPoints,
totalPoints: totalPoints,
pointsConfig: pointsConfig
};
});