Merge pull request #69 from quoije/2026
Merge 2026 branch into prod
This commit was merged in pull request #69.
This commit is contained in:
+16
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+221
-4
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 The Brutalist Dune: Part Two" required></textarea>
|
||||
<div class="form-text">Entrez un nommé par ligne.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-primary">Créer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xxl-5">
|
||||
<div class="border rounded p-3 bg-white">
|
||||
<div class="fw-semibold mb-2">Note</div>
|
||||
<div class="text-muted">
|
||||
Vous pouvez créer plusieurs catégories pour la même année.
|
||||
Le numéro de catégorie doit être unique par année.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="tab-pane fade" id="admin-categories-pane-list" role="tabpanel" aria-labelledby="admin-categories-subtab-list" tabindex="0">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Catégories importées</div>
|
||||
<div class="text-muted small">Gérez les catégories existantes</div>
|
||||
</div>
|
||||
<div class="d-flex 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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user