Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2821b55e4b | ||
|
|
dd313f8f5e | ||
|
|
6186f3a051 | ||
|
|
d8330579ec | ||
|
|
087f4f4023 | ||
|
|
cc729de70e | ||
|
|
7a629b6c91 | ||
|
|
ab67206c71 | ||
|
|
c81135a4c1 | ||
|
|
12d9d641eb | ||
|
|
9868d2c39a | ||
|
|
b0ff5e598e | ||
|
|
0ae3af13f1 | ||
|
|
1492e5e568 | ||
|
|
cf57eee5a3 | ||
|
|
792f2350ad | ||
|
|
a17d548431 | ||
|
|
341a5e6c61 | ||
|
|
b75be09aec | ||
|
|
36057d75df | ||
|
|
e8d838318e | ||
|
|
1d41cd29d9 | ||
|
|
cde6971544 | ||
|
|
8ee3c5aa5b | ||
|
|
01c478aafe | ||
|
|
67040095bc | ||
|
|
6f66eef0d4 | ||
|
|
6543ed172b | ||
|
|
c2f03230b2 | ||
|
|
7ebd57931c | ||
|
|
a30bbcf514 | ||
|
|
55a93a0ac5 | ||
|
|
86a25b77e8 | ||
|
|
8790f46a00 | ||
|
|
fe21a12c82 | ||
|
|
23ac8e07a3 | ||
|
|
406a4f0bb3 | ||
|
|
891eb67e2d | ||
|
|
a9592ece7a | ||
|
|
9076461c7e | ||
|
|
f9d8722b4e | ||
|
|
1e16b964e6 | ||
|
|
b00280e9e4 | ||
|
|
be47f3830d | ||
|
|
d10164076c | ||
|
|
b5827acfd1 | ||
|
|
211c115d41 | ||
|
|
55fe658046 | ||
|
|
fb07c81a43 | ||
|
|
b6df70864d | ||
|
|
ccf7340727 | ||
|
|
e8dabad3c2 | ||
|
|
6a2ec537e9 | ||
|
|
7d8077774b | ||
|
|
dc726b9b71 | ||
|
|
b39849f745 | ||
|
|
9979682beb | ||
|
|
bf45de3bbc | ||
|
|
70b309d1ae | ||
|
|
1281d77c4e | ||
|
|
38b4d03c4e | ||
|
|
7722ac89c3 | ||
|
|
1c4b18f8aa | ||
|
|
b2da6de9b3 | ||
|
|
ae5a5ee073 | ||
|
|
e0d5531041 | ||
|
|
0089d2f376 | ||
|
|
79b9f4a5a3 | ||
|
|
82f9462dda | ||
|
|
0b1709c2ed | ||
|
|
647a0b6a47 | ||
|
|
eeaf2592ea | ||
|
|
59eb70dea9 | ||
|
|
57918f8b88 | ||
|
|
2da9e44105 | ||
|
|
8c4941b0a2 | ||
|
|
9d3376bbb0 | ||
|
|
421206d280 | ||
|
|
1a460ca06c | ||
|
|
a75a024249 | ||
|
|
d933d69b5f | ||
|
|
5325576dc4 | ||
|
|
812590d262 | ||
|
|
1b27b378ec | ||
|
|
20d0a52f78 | ||
|
|
dfd8e9b4fd | ||
|
|
0c488319f6 | ||
|
|
76e9a934d6 | ||
|
|
88129ca5ac | ||
|
|
e91c919b46 | ||
|
|
fd881399cd | ||
|
|
8d93c31df8 | ||
|
|
f65d92743e | ||
|
|
1cda3c23e1 | ||
|
|
ce234d0fac | ||
|
|
8590c30596 | ||
|
|
53eb7faf06 | ||
|
|
60fd5a25ce | ||
|
|
2025f0231c | ||
|
|
ea37bf95ef | ||
|
|
e6537884a3 | ||
|
|
837e02ef4c | ||
|
|
2f767e15a6 | ||
|
|
f54b1d91c5 | ||
|
|
e6f3b02830 | ||
|
|
3b055bfdeb | ||
|
|
1f3511b455 | ||
|
|
c79b3f1e16 | ||
|
|
f5bd8709a0 | ||
|
|
ad211b11bb | ||
|
|
1bc65ebd77 | ||
|
|
6d7269f331 | ||
|
|
d9ed2cdc92 | ||
|
|
282bc1750c | ||
|
|
00f5328c75 | ||
|
|
d683f04305 | ||
|
|
74c833b62c | ||
|
|
fa0ba74682 | ||
|
|
c7de8b8db0 | ||
|
|
377c5a828e | ||
|
|
4809c8ad85 | ||
|
|
34852ce931 | ||
|
|
b764d26ad0 | ||
|
|
762f75ad62 | ||
|
|
e318e25ed1 | ||
|
|
aeff3504f0 | ||
|
|
3884ce6492 | ||
|
|
1e7ab962d6 | ||
|
|
e326ead817 | ||
|
|
d732e1a013 | ||
|
|
05a4b9a673 | ||
|
|
3c2ddf66ff | ||
|
|
122c7ee8d3 | ||
|
|
52eaee65f6 | ||
|
|
e6f3998d88 | ||
|
|
a7b7df8689 | ||
|
|
9a7063b9dc | ||
|
|
5a2b40357e | ||
|
|
58d5c3bd40 | ||
|
|
595319945d | ||
|
|
20ff670ae2 | ||
|
|
7f0b7834e6 | ||
|
|
ee483e342c | ||
|
|
448c28caee | ||
|
|
beaeaf57dd | ||
|
|
1b7a7f87a6 | ||
|
|
87396f72b8 | ||
|
|
64605cf24d | ||
|
|
d87dff1c9d | ||
|
|
9e825eb1a6 | ||
|
|
d0edfe9278 | ||
|
|
4dced844e4 | ||
|
|
12ea9ef160 | ||
|
|
4fe8ad84ab | ||
|
|
1f98a1a24a | ||
|
|
afb158beca | ||
|
|
253ac63fe9 | ||
|
|
0fe87e1971 | ||
|
|
b2882e01d2 | ||
|
|
59d382cf37 | ||
|
|
f602808ef6 | ||
|
|
6532ca85f8 | ||
|
|
19e5f1d522 | ||
|
|
9234c2ed1e | ||
|
|
b2260f6028 | ||
|
|
0c5ac630e1 | ||
|
|
dbaed3a629 | ||
|
|
393f942d19 | ||
|
|
1f2462b01e | ||
|
|
206ca2e9d7 | ||
|
|
b72f1bde82 | ||
|
|
7a7d94968b | ||
|
|
5cfa35c79c | ||
|
|
43925c3b60 | ||
|
|
ae1f065000 | ||
|
|
2a74ed38de | ||
|
|
e3baaf0fc7 | ||
|
|
665cca7164 | ||
|
|
f073f81c57 | ||
|
|
975e922c40 | ||
|
|
4aa969414d | ||
|
|
1389b004f6 | ||
|
|
a1c781e8a5 | ||
|
|
ef97ff0730 | ||
|
|
232f88d61a | ||
|
|
35da8274b0 | ||
|
|
bc017088f7 | ||
|
|
ff289c38da | ||
|
|
c83b4685a2 | ||
|
|
7a5ef7e9a0 | ||
|
|
6cb503fe35 | ||
|
|
03a4b4e5dc | ||
|
|
7ff02207f6 | ||
|
|
dda483b757 | ||
|
|
64cfdc3c6d | ||
|
|
7c20a2b2ae | ||
|
|
e0ec4760ce | ||
|
|
9ac9b1bd04 | ||
|
|
9854ec30f5 | ||
|
|
1b9174bf0d | ||
|
|
eeb337fe08 | ||
|
|
da9d746928 | ||
|
|
0d35a19cc5 | ||
|
|
5c4366a9e7 | ||
|
|
eaf7904940 | ||
|
|
f07547cc59 | ||
|
|
6108c8a40f | ||
|
|
6110877ba3 |
+37
@@ -137,3 +137,40 @@ dist
|
||||
|
||||
# ayylmao
|
||||
ayylmao.js
|
||||
|
||||
# Python video server - generated TLS material (certbot state)
|
||||
python_video_server/certs/
|
||||
|
||||
# Backup files
|
||||
backups/
|
||||
|
||||
# Video files in public/video/
|
||||
public/video/*
|
||||
|
||||
# Subtitles
|
||||
subtitles/*
|
||||
|
||||
# Uploads directory (temporary files)
|
||||
uploads/
|
||||
|
||||
# Example files
|
||||
exemple/
|
||||
|
||||
# OCR training data
|
||||
*.traineddata
|
||||
eng.traineddata
|
||||
|
||||
# OS and editor files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Temporary scripts
|
||||
temp/
|
||||
@@ -1,18 +1,101 @@
|
||||
## Oscars Pool 2025
|
||||
Little nodejs + mongodb app that I hack together with ChatGPT to keep track of watched movies for an incoming Oscars Pool with some friends. App is not secure, using old html standard and is not the most responsive but does the job.
|
||||
## 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.
|
||||
|
||||
Would probably need to revamp everything, but didn't want to spend more time on it.
|
||||
**Note:** The application interface is in French.
|
||||
|
||||

|
||||
**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### User Features
|
||||
- **User accounts**: register, login, JWT authentication
|
||||
- **Dark mode**: follows system preference by default, with a user toggle (persisted per user)
|
||||
- **Mobile-friendly UI**: improved responsive navbar + controls for small screens
|
||||
- **Optional registration verification**: via `DOG_NAMES` environment variable (if unset, verification is skipped)
|
||||
- **Multi-year support**: browse movies and stats by Oscar year (admin controls the active year)
|
||||
- **Checklist + progress**: mark movies as watched (with date), progress bar, countdown to Oscars date
|
||||
- **Movie details**: pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional)
|
||||
- **Movie ratings**: 1–5 star user ratings + global average
|
||||
- **User statistics**: watched count, points from movies watched and Oscar picks, leaderboard
|
||||
- **Video player**: direct video URL, server file (HD/low), embed, or legacy VOD link
|
||||
- **Subtitles**: per-movie subtitle tracks in the player (VTT/SRT)
|
||||
- **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
|
||||
|
||||
### Admin Features
|
||||
- **Movie management**: add, edit (optionally refresh from OMDb), delete movies, search/bulk delete
|
||||
- **Player source management**: configure video source, server files, low-quality fallback, subtitles
|
||||
- **User management**: view/create/delete 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 per category and overall winners by year (ties + optional points)
|
||||
- **View all picks**: see everyone's picks in a comparison view
|
||||
- **Global settings**: active Oscar year, Oscars date for countdown, points config, picks visibility
|
||||
- **Completion modal**: customize 100% completion reward (title/text/HTML/video)
|
||||
- **Database backup/restore**: create, download, and restore backups
|
||||
- **App version control**: manage app version display (footer) from admin panel (no GitHub dependency)
|
||||
- **Player UI settings**: control player page admin status display
|
||||
|
||||
## Setup
|
||||
Config those in your .env file:
|
||||
Create a `.env` file at the project root (same folder as `package.json`).
|
||||
|
||||
**DOG_NAMES=**`woof, WOOF, Woof` (for "verification")<br>
|
||||
**GITHUB_OWNER=**`quoije` (for version history)<br>
|
||||
**GITHUB_REPO=**`oscars-pool` (for version history)<br>
|
||||
**JWT_SECRET=**`RANDOM STRING` (for authentication token)<br>
|
||||
**MONGO_URI=**`mongodb+srv://USER:[email protected]/?retryWrites=true&w=majority&appName=Cluster69`<br>
|
||||
**OMDB_API=**`1234567` (for movie info)
|
||||
Minimal steps:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
node index.js
|
||||
```
|
||||
|
||||
Add this to your `.env` (**required**: `MONGO_URI`, `JWT_SECRET`):
|
||||
|
||||
```bash
|
||||
# required
|
||||
MONGO_URI=mongodb://127.0.0.1:27017/oscars-pool # MongoDB connection string
|
||||
JWT_SECRET=replace-with-a-random-string # JWT signing secret (auth)
|
||||
|
||||
# optional
|
||||
DOG_NAMES=woof,WOOF,Woof # registration verification answers (comma-separated)
|
||||
OMDB_API=your_omdb_key # OMDb API key (movie info)
|
||||
```
|
||||
|
||||
Generate a good `JWT_SECRET`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## Database (MongoDB)
|
||||
This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_URI`.
|
||||
|
||||
- Use **MongoDB Atlas** (recommended) or a local MongoDB instance.
|
||||
- `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/`:
|
||||
- `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):
|
||||
|
||||
```bash
|
||||
PORT=5000 # server port (default: 5000)
|
||||
JWT_EXPIRES_IN=8h # JWT expiry (default: 8h)
|
||||
VIDEO_FILES_DIR=/abs/path/to/video # where server video files live (default: ./public/video)
|
||||
VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h)
|
||||
MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The server serves the UI from `public/` (open `http://localhost:5000` by default).
|
||||
- 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 setup and usage instructions.
|
||||
|
||||
run with **node .\index.js**
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 868 KiB |
@@ -2,12 +2,14 @@ const express = require("express");
|
||||
const mongoose = require("mongoose");
|
||||
const dotenv = require("dotenv");
|
||||
const cors = require("cors");
|
||||
const compression = require("compression");
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
// Connect to MongoDB
|
||||
@@ -20,12 +22,24 @@ mongoose
|
||||
const userRoutes = require("./routes/users");
|
||||
const movieRoutes = require("./routes/movies");
|
||||
const versionRoutes = require("./routes/version");
|
||||
const settingsRoutes = require("./routes/settings");
|
||||
const adminDbRoutes = require("./routes/adminDb");
|
||||
const videoRoutes = require("./routes/video");
|
||||
const subtitlesRoutes = require("./routes/subtitles");
|
||||
const picksRoutes = require("./routes/picks");
|
||||
const categoriesRoutes = require("./routes/categories");
|
||||
|
||||
app.use(express.static('public'));
|
||||
app.use("/api/users", userRoutes);
|
||||
app.use("/api/movies", movieRoutes);
|
||||
app.use("/api/version", versionRoutes);
|
||||
app.use("/api/settings", settingsRoutes);
|
||||
app.use("/api/admin", adminDbRoutes);
|
||||
app.use("/api/video", videoRoutes);
|
||||
app.use("/api/subtitles", subtitlesRoutes);
|
||||
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}`));
|
||||
|
||||
+38
-2
@@ -5,10 +5,46 @@ const movieSchema = new mongoose.Schema({
|
||||
title: { type: String, required: false },
|
||||
description: { type: String, required: false },
|
||||
rating: { type: String, required: false },
|
||||
runtime: { type: String, required: false },
|
||||
poster: { type: String, required: false },
|
||||
cam: { type: Boolean, required: false, default: false },
|
||||
// Oscar pool year / edition (used to segment movies + stats)
|
||||
year: { type: Number, required: false, index: true },
|
||||
category: { type: String, required: true },
|
||||
vod_link: { type: String, required: true },
|
||||
// Legacy "one field" player source.
|
||||
// Kept for backwards-compat. New UI prefers video_src / embed_src.
|
||||
vod_link: { type: String, required: false },
|
||||
|
||||
// Player configuration (optional; used by player.html)
|
||||
player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" },
|
||||
video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8)
|
||||
embed_src: { type: String, required: false }, // URL or iframe code
|
||||
// Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video)
|
||||
// If set, the backend can stream it via /api/video/:movieId (Range/seek supported).
|
||||
video_file: { type: String, required: false },
|
||||
// Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video)
|
||||
// Served when the player requests ?quality=low on /api/video/:movieId.
|
||||
video_file_low: { type: String, required: false },
|
||||
// Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId/:subtitleId)
|
||||
subtitles: [{
|
||||
file: { type: String, required: true },
|
||||
lang: { type: String, required: false },
|
||||
label: { type: String, required: false },
|
||||
default: { type: Boolean, required: false, default: false },
|
||||
}],
|
||||
// Legacy single-subtitle fields (kept for backward compatibility)
|
||||
subtitle_file: { type: String, required: false },
|
||||
subtitle_lang: { type: String, required: false },
|
||||
subtitle_label: { type: String, required: false },
|
||||
subtitle_default: { type: Boolean, required: false, default: false },
|
||||
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
|
||||
});
|
||||
}, { timestamps: true });
|
||||
|
||||
// Common read patterns:
|
||||
// - list movies for a year, sorted by title
|
||||
// - check "last update" per year (sort by updatedAt)
|
||||
movieSchema.index({ year: 1, title: 1 });
|
||||
movieSchema.index({ year: 1, updatedAt: -1 });
|
||||
movieSchema.index({ year: 1, imdb_id: 1 });
|
||||
|
||||
module.exports = mongoose.model("Movie", movieSchema);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const movieRatingSchema = new mongoose.Schema({
|
||||
movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
|
||||
rating: { type: Number, required: true, min: 1, max: 5 },
|
||||
}, { timestamps: true });
|
||||
|
||||
movieRatingSchema.index({ movieId: 1, userId: 1 }, { unique: true });
|
||||
|
||||
module.exports = mongoose.model("MovieRating", movieRatingSchema);
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const playbackProgressSchema = new mongoose.Schema(
|
||||
{
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
|
||||
movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true },
|
||||
// seconds
|
||||
time: { type: Number, default: 0 },
|
||||
// seconds (optional, best-effort from the client)
|
||||
duration: { type: Number, default: null },
|
||||
updatedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ versionKey: false }
|
||||
);
|
||||
|
||||
// One progress row per user + movie
|
||||
playbackProgressSchema.index({ userId: 1, movieId: 1 }, { unique: true });
|
||||
|
||||
module.exports = mongoose.model("PlaybackProgress", playbackProgressSchema);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Simple key/value store for global site settings.
|
||||
// We currently use it for the "active Oscar year" that drives filtering.
|
||||
const settingSchema = new mongoose.Schema(
|
||||
{
|
||||
key: { type: String, required: true, unique: true, index: true },
|
||||
value: { type: mongoose.Schema.Types.Mixed, required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
module.exports = mongoose.model("Setting", settingSchema);
|
||||
|
||||
@@ -5,6 +5,12 @@ const userSchema = new mongoose.Schema({
|
||||
email: { type: String, required: true, unique: true },
|
||||
password: { type: String, required: true },
|
||||
role: { type: Number, default: 0 },
|
||||
// If true, user must change password on next login (e.g. after admin reset)
|
||||
mustChangePassword: { type: Boolean, default: false },
|
||||
tempPasswordIssuedAt: { type: Date, default: null },
|
||||
tempPasswordExpiresAt: { type: Date, default: null },
|
||||
// Optional. When unset, UI follows system preference.
|
||||
themePreference: { type: String, enum: ["light", "dark"], default: undefined },
|
||||
watchedMovies: [{
|
||||
imdb_id: { type: String, required: false },
|
||||
watchedDate: { type: Date, default: Date.now } // Store the watched date
|
||||
|
||||
Generated
+482
-39
@@ -12,12 +12,15 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.3",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express": "^4.22.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"mongoose": "^8.9.5"
|
||||
"mongoose": "^8.9.5",
|
||||
"multer": "^2.0.2",
|
||||
"pdf-parse": "^2.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
@@ -32,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",
|
||||
@@ -74,6 +261,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -87,13 +280,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -148,9 +341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -186,6 +379,23 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -261,6 +471,45 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -268,6 +517,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -434,6 +698,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -450,39 +729,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -495,6 +774,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -547,13 +841,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
|
||||
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -686,6 +982,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -823,23 +1134,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"jwa": "^1.4.2",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -982,6 +1293,27 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
@@ -1137,6 +1469,24 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1243,6 +1593,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1258,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",
|
||||
@@ -1345,6 +1736,20 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -1385,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"
|
||||
@@ -1565,6 +1970,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -1635,6 +2057,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -1651,6 +2079,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -1690,6 +2124,15 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -13,12 +13,15 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.3",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express": "^4.22.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"mongoose": "^8.9.5"
|
||||
"mongoose": "^8.9.5",
|
||||
"multer": "^2.0.2",
|
||||
"pdf-parse": "^2.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars - Changer le mot de passe</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="container d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="card p-4" style="max-width: 420px; width: 100%;">
|
||||
<div class="text-center mb-3">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
Votre compte utilise un <strong>mot de passe temporaire</strong>. Veuillez définir un nouveau mot de passe pour continuer.
|
||||
</div>
|
||||
|
||||
<div id="change-password-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
|
||||
<form id="change-password-form">
|
||||
<div class="mb-3">
|
||||
<label for="new-password" class="form-label">Nouveau mot de passe</label>
|
||||
<input type="password" class="form-control" id="new-password" minlength="8" required>
|
||||
<div class="form-text">Minimum 8 caractères.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm-password" class="form-label">Confirmer le mot de passe</label>
|
||||
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./js/app_change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+109
-33
@@ -3,9 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars 2025 - Checklist</title>
|
||||
<title>Pool Oscars - Checklist</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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;
|
||||
@@ -42,21 +45,27 @@
|
||||
.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");
|
||||
}
|
||||
/* New styles for table */
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: #f1f1f1;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<body class="bg-light page-loading">
|
||||
|
||||
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
|
||||
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
|
||||
<a class="navbar-brand" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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>
|
||||
@@ -64,44 +73,101 @@
|
||||
<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="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</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 -->
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
<span id="user-name" class="navbar-text me-3"></span>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<h2>Checklist</h2>
|
||||
<div id="watched-ratio" class="ratio"></div>
|
||||
<h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3>
|
||||
<h3 class="fs-6"><span id="movies-per-day"></span></h3>
|
||||
<div class="progress my-3">
|
||||
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<div class="container mt-4">
|
||||
<!-- Summary card (match Stats page spacing/structure) -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<div id="watched-ratio" class="fw-semibold">—</div>
|
||||
<div class="text-muted small">
|
||||
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
|
||||
</div>
|
||||
<div class="text-muted small"><span id="movies-per-day"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="progress mt-3" style="height: 10px;">
|
||||
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-busy="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table for movies -->
|
||||
<table class="table table-hover movie-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Vu</th>
|
||||
<th scope="col">Titre</th>
|
||||
<th scope="col">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="movie-table-body">
|
||||
<!-- Movie rows will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-responsive mt-3">
|
||||
<table class="table table-striped table-hover align-middle movie-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Vu</th>
|
||||
<th scope="col">Titre</th>
|
||||
<th scope="col">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="movie-table-body">
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<br><br>
|
||||
</div>
|
||||
|
||||
<!-- Include confetti-js library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
|
||||
|
||||
<!-- Uncheck Confirmation Modal -->
|
||||
<div class="modal fade" id="uncheckConfirmModal" tabindex="-1" aria-labelledby="uncheckConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="uncheckConfirmModalLabel">Confirmer le retrait</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Es-tu sûr de vouloir retirer <strong id="uncheckMovieTitle"></strong> de ta liste?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmUncheckBtn">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reward Modal -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@@ -111,8 +177,11 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<video id="rewardVideo" width="75%" controls>
|
||||
<source src="video/reward.mp4" type="video/mp4">
|
||||
<div id="completion-modal-custom" class="text-start d-none"></div>
|
||||
<div id="completion-modal-text" class="text-start mb-3" style="white-space: pre-wrap;"></div>
|
||||
|
||||
<video id="rewardVideo" width="75%" controls class="d-none">
|
||||
<source id="rewardVideoSource" src="video/reward.mp4" type="video/mp4">
|
||||
Votre navigateur ne supporte pas la vidéo.
|
||||
</video>
|
||||
</div>
|
||||
@@ -120,8 +189,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./js/app_checklist.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+1400
File diff suppressed because it is too large
Load Diff
+1377
-4
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
+58
-11
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars 2025</title>
|
||||
<title>Pool Oscars</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
<style>
|
||||
@@ -23,11 +24,65 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<div class="card p-4" style="max-width: 400px; margin: auto;">
|
||||
<!-- First-time setup card (hidden by default) -->
|
||||
<div id="setup-card" class="card p-4 d-none" style="max-width: 450px; margin: auto;">
|
||||
<div class="text-center mb-3">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Première installation</strong><br>
|
||||
Aucun administrateur n'existe. Créez le premier compte admin pour commencer.
|
||||
</div>
|
||||
<form id="setup-form">
|
||||
<div class="mb-3">
|
||||
<label for="setup-name" class="form-label">Nom</label>
|
||||
<input type="text" class="form-control" id="setup-name" placeholder="Votre nom" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-email" class="form-label">Courriel</label>
|
||||
<input type="email" class="form-control" id="setup-email" placeholder="[email protected]" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-password" class="form-label">Mot de passe</label>
|
||||
<input type="password" class="form-control" id="setup-password" minlength="6" required>
|
||||
<div class="form-text">Minimum 6 caractères</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-password-confirm" class="form-label">Confirmer le mot de passe</label>
|
||||
<input type="password" class="form-control" id="setup-password-confirm" minlength="6" required>
|
||||
</div>
|
||||
<div id="setup-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
<button type="submit" class="btn btn-warning w-100">Créer le compte admin</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Normal login card -->
|
||||
<div id="login-card" class="card p-4" style="max-width: 400px; margin: auto;">
|
||||
<div class="text-center mb-4">
|
||||
<img src="img/logo.png" style="width: 80%;">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="mb-3">
|
||||
@@ -43,18 +98,10 @@
|
||||
<p class="mt-3 text-center">Pas de compte? <a href="/register.html">Enregistrez-vous</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
|
||||
<p id="footer-copy"><a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a> - deux mille vingt-cinq</p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./js/app_index.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
function decodeJwt(token) {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
}
|
||||
|
||||
function isTokenExpired(decoded) {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return !!decoded.exp && decoded.exp < currentTime;
|
||||
}
|
||||
|
||||
async function applyActiveOscarYearToTitle() {
|
||||
const base = 'Pool Oscars';
|
||||
const current = String(document.title || base);
|
||||
const sep = ' - ';
|
||||
const idx = current.indexOf(sep);
|
||||
const suffix = idx >= 0 ? current.slice(idx) : '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
||||
const data = res.ok ? await res.json().catch(() => null) : null;
|
||||
const year = Number(data?.year);
|
||||
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
|
||||
} catch (_) {
|
||||
document.title = `${base}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
// Best-effort: update page title with active year.
|
||||
applyActiveOscarYearToTitle();
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeJwt(token);
|
||||
} catch (e) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTokenExpired(decoded)) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show this page if user must change password.
|
||||
if (!decoded.mustChangePassword) {
|
||||
window.location.href = '/movies.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const errorEl = document.getElementById('change-password-error');
|
||||
const form = document.getElementById('change-password-form');
|
||||
|
||||
function showError(message) {
|
||||
errorEl.classList.remove('d-none');
|
||||
errorEl.textContent = message;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorEl.classList.add('d-none');
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
|
||||
document.getElementById('log-off').addEventListener('click', function () {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
|
||||
const newPassword = document.getElementById('new-password').value;
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
showError('Le mot de passe doit contenir au moins 8 caractères.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showError('Les mots de passe ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
showError(data.message || data.error || `Erreur (${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
} else {
|
||||
// Fallback: force re-login if no token is returned.
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = '/movies.html';
|
||||
} catch (err) {
|
||||
showError(err.message || 'Erreur réseau');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+456
-55
@@ -1,17 +1,310 @@
|
||||
window.onload = async function () {
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
let navResizeObserver = null;
|
||||
|
||||
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
|
||||
const preloadedOverlay = document.getElementById('page-loader-overlay');
|
||||
const overlay = preloadedOverlay || document.createElement('div');
|
||||
if (!preloadedOverlay) {
|
||||
overlay.className = 'page-loader-overlay';
|
||||
overlay.setAttribute('role', 'status');
|
||||
overlay.setAttribute('aria-live', 'polite');
|
||||
overlay.setAttribute('aria-busy', 'true');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">${title}</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Keep screen reader label up to date.
|
||||
const label = overlay.querySelector('.visually-hidden');
|
||||
if (label) label.textContent = title;
|
||||
}
|
||||
|
||||
const barEl = () => overlay.querySelector('#page-loader-bar');
|
||||
|
||||
function clampPct(p) {
|
||||
const n = Number(p);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function setProgress(p) {
|
||||
progress = clampPct(p);
|
||||
const bar = barEl();
|
||||
if (bar) bar.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
function setSubtitle(_) {
|
||||
// Intentionally no-op (we only show the bar now).
|
||||
}
|
||||
|
||||
function getNavHeightPx() {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (!nav) return 0;
|
||||
const rect = nav.getBoundingClientRect();
|
||||
const h = Number(rect?.height) || 0;
|
||||
return h > 0 ? Math.round(h) : 0;
|
||||
}
|
||||
|
||||
function updateOverlayTopOffset() {
|
||||
// Scope the offset to this overlay instance (no global CSS var).
|
||||
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
|
||||
}
|
||||
|
||||
function ensureMounted() {
|
||||
if (removed) return;
|
||||
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
|
||||
// we still need to compute the navbar offset. Otherwise it defaults to 0px
|
||||
// and the blur covers the header.
|
||||
updateOverlayTopOffset();
|
||||
document.body.classList.add('page-loading');
|
||||
|
||||
if (!overlay.isConnected) document.body.appendChild(overlay);
|
||||
|
||||
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
|
||||
if (!navResizeObserver) {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (nav && typeof ResizeObserver !== 'undefined') {
|
||||
try {
|
||||
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
|
||||
navResizeObserver.observe(nav);
|
||||
} catch (_) {
|
||||
navResizeObserver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideAndRemoveSoon() {
|
||||
if (removed) return;
|
||||
overlay.classList.add('page-loader-hide');
|
||||
overlay.setAttribute('aria-busy', 'false');
|
||||
document.body.classList.remove('page-loading');
|
||||
window.setTimeout(() => {
|
||||
removed = true;
|
||||
if (navResizeObserver) {
|
||||
try { navResizeObserver.disconnect(); } catch (_) {}
|
||||
navResizeObserver = null;
|
||||
}
|
||||
try { overlay.remove(); } catch (_) {}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function done() {
|
||||
setProgress(100);
|
||||
hideAndRemoveSoon();
|
||||
}
|
||||
|
||||
function fail(_) {
|
||||
// Don't block the UI on errors: let the page render its own error message.
|
||||
setProgress(Math.max(progress, 95));
|
||||
window.setTimeout(() => hideAndRemoveSoon(), 200);
|
||||
}
|
||||
|
||||
ensureMounted();
|
||||
setProgress(8);
|
||||
|
||||
return { setProgress, setSubtitle, done, fail };
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async function () {
|
||||
const pageLoader = createPageLoader({
|
||||
title: 'Chargement de la checklist',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const movieTableBody = document.getElementById('movie-table-body');
|
||||
const watchedRatioEl = document.getElementById('watched-ratio');
|
||||
|
||||
function showTableError(message) {
|
||||
if (!movieTableBody) return;
|
||||
movieTableBody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="alert alert-danger my-2" role="alert">
|
||||
${message || 'Erreur lors du chargement.'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||
title: 'Félicitations! very nice 🎉🎉🎉',
|
||||
bodyText: '',
|
||||
videoSrc: 'video/reward.mp4',
|
||||
bodyHtml: ''
|
||||
});
|
||||
|
||||
function sanitizeHtml(raw) {
|
||||
const html = String(raw || '');
|
||||
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
|
||||
const withoutOnAttrs = withoutScripts
|
||||
.replace(/\son\w+="[^"]*"/gi, '')
|
||||
.replace(/\son\w+='[^']*'/gi, '')
|
||||
.replace(/\son\w+=\S+/gi, '');
|
||||
return withoutOnAttrs.trim();
|
||||
}
|
||||
|
||||
async function fetchCompletionModalContent() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch completion modal');
|
||||
const data = await res.json();
|
||||
return {
|
||||
title: typeof data?.title === 'string' ? data.title : DEFAULT_COMPLETION_MODAL.title,
|
||||
bodyText: typeof data?.bodyText === 'string' ? data.bodyText : DEFAULT_COMPLETION_MODAL.bodyText,
|
||||
videoSrc: typeof data?.videoSrc === 'string' ? data.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc,
|
||||
bodyHtml: typeof data?.bodyHtml === 'string' ? data.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml,
|
||||
};
|
||||
} catch (_) {
|
||||
return DEFAULT_COMPLETION_MODAL;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCompletionModalContent(content) {
|
||||
const titleEl = document.getElementById('videoModalLabel');
|
||||
const bodyTextEl = document.getElementById('completion-modal-text');
|
||||
const bodyCustomEl = document.getElementById('completion-modal-custom');
|
||||
const videoEl = document.getElementById('rewardVideo');
|
||||
const videoSourceEl = document.getElementById('rewardVideoSource');
|
||||
|
||||
const title = String(content?.title || '').trim() || DEFAULT_COMPLETION_MODAL.title;
|
||||
const bodyText = String(content?.bodyText || '');
|
||||
const bodyHtml = sanitizeHtml(String(content?.bodyHtml || ''));
|
||||
const videoSrc = String(content?.videoSrc || '').trim();
|
||||
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
|
||||
if (bodyCustomEl) {
|
||||
if (bodyHtml) {
|
||||
bodyCustomEl.innerHTML = bodyHtml;
|
||||
bodyCustomEl.classList.remove('d-none');
|
||||
} else {
|
||||
bodyCustomEl.innerHTML = '';
|
||||
bodyCustomEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyTextEl) {
|
||||
bodyTextEl.textContent = bodyText || '';
|
||||
bodyTextEl.classList.toggle('d-none', !bodyText);
|
||||
}
|
||||
|
||||
if (videoEl && videoSourceEl) {
|
||||
if (videoSrc) {
|
||||
videoSourceEl.setAttribute('src', videoSrc);
|
||||
// reload source (so updated src is used)
|
||||
try { videoEl.load(); } catch (_) {}
|
||||
videoEl.classList.remove('d-none');
|
||||
} else {
|
||||
try { videoEl.pause(); } catch (_) {}
|
||||
videoEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveYear() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||
const data = await res.json();
|
||||
const year = Number(data?.year);
|
||||
return Number.isInteger(year) ? year : new Date().getFullYear();
|
||||
} catch (_) {
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOscarEffectiveDate(year) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
||||
const data = await res.json();
|
||||
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
||||
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
|
||||
} catch (_) {
|
||||
return `${year}-03-15`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocalNoonFromIsoDate(dateStr) {
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
async function fetchMoviesSummary(year) {
|
||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
const activeYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${activeYear}) - Checklist`;
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
// Render countdown immediately with a deterministic fallback (then refine from settings).
|
||||
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
|
||||
const timeLeftEl = document.getElementById('time-left');
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
|
||||
const targetDate = new Date('March 2, 2025');
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
||||
let timeDifference = fallbackDate - currentDate;
|
||||
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
fallbackDate;
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
const now = new Date();
|
||||
timeDifference = targetDate - now;
|
||||
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}).catch(() => {});
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(token.split('.')[1]));
|
||||
if (decoded.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
}
|
||||
const userName = decoded.name;
|
||||
document.getElementById('user-name').textContent = userName;
|
||||
if (decoded.admin) {
|
||||
const adminLink = document.getElementById('admin-control-link');
|
||||
if (adminLink) adminLink.classList.remove('d-none');
|
||||
}
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
if (decoded.exp && decoded.exp < currentTime) {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -25,35 +318,72 @@ window.onload = async function () {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
const res = await fetch('/api/movies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (watchedRatioEl) watchedRatioEl.textContent = '—';
|
||||
const progressBarEl = document.getElementById('progress-bar');
|
||||
pageLoader.setProgress(18);
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
let movies = [];
|
||||
let watchedMovies = [];
|
||||
let watchedMoviesInYear = [];
|
||||
let movieImdbIds = new Set();
|
||||
try {
|
||||
// Start summary fetch immediately so the header can update without waiting for the full list.
|
||||
const summaryPromise = fetchMoviesSummary(activeYear)
|
||||
.then((summary) => {
|
||||
const totalCount = Number(summary?.totalMoviesCount) || 0;
|
||||
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
|
||||
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
|
||||
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
|
||||
updateProgressBar(watchedCount, totalCount, false);
|
||||
pageLoader.setProgress(32);
|
||||
return summary;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
// Fetch remaining data in parallel.
|
||||
pageLoader.setSubtitle('Chargement de la liste des films…');
|
||||
pageLoader.setProgress(26);
|
||||
const [completionModalContent, moviesRes] = await Promise.all([
|
||||
fetchCompletionModalContent(),
|
||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
})
|
||||
]);
|
||||
|
||||
applyCompletionModalContent(completionModalContent);
|
||||
pageLoader.setProgress(55);
|
||||
|
||||
if (!moviesRes.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
movies = await moviesRes.json();
|
||||
pageLoader.setProgress(62);
|
||||
|
||||
// Sort movies alphabetically by title
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
const summary = await summaryPromise;
|
||||
watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
|
||||
|
||||
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
|
||||
} catch (e) {
|
||||
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
|
||||
pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.');
|
||||
return;
|
||||
}
|
||||
|
||||
const movies = await res.json();
|
||||
|
||||
// Sort movies alphabetically by title
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
const watchedRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
let watchedMovies = [];
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
}
|
||||
movieImdbIds = new Set(movies.map((m) => m.imdb_id).filter(Boolean));
|
||||
watchedMoviesInYear = watchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
|
||||
|
||||
const totalMoviesCount = movies.length;
|
||||
const watchedMoviesCount = watchedMovies.length;
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
||||
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||
|
||||
function launchConfetti() {
|
||||
const duration = 3 * 1000; // 3 seconds
|
||||
@@ -80,10 +410,12 @@ window.onload = async function () {
|
||||
frame();
|
||||
}
|
||||
|
||||
function updateProgressBar(watchedCount, totalCount) {
|
||||
function updateProgressBar(watchedCount, totalCount, celebrate = true) {
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const percentage = (watchedCount / totalCount) * 100;
|
||||
console.log(percentage);
|
||||
if (!progressBar) return;
|
||||
const safeTotal = Number(totalCount);
|
||||
const safeWatched = Number(watchedCount);
|
||||
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
|
||||
if (percentage <= 50) {
|
||||
@@ -94,20 +426,25 @@ window.onload = async function () {
|
||||
|
||||
progressBar.setAttribute('aria-valuenow', Math.round(percentage));
|
||||
|
||||
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
|
||||
|
||||
if (percentage === 100) {
|
||||
videoModal.show();
|
||||
launchConfetti();
|
||||
document.getElementById("rewardVideo").play();
|
||||
if (celebrate) {
|
||||
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
|
||||
if (percentage === 100) {
|
||||
videoModal.show();
|
||||
launchConfetti();
|
||||
const rewardVideo = document.getElementById("rewardVideo");
|
||||
// Only attempt autoplay if we actually have a visible <video>.
|
||||
if (rewardVideo && !rewardVideo.classList.contains('d-none')) {
|
||||
rewardVideo.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBar(watchedMoviesCount, totalMoviesCount);
|
||||
|
||||
const movieTableBody = document.getElementById('movie-table-body');
|
||||
if (movieTableBody) movieTableBody.innerHTML = '';
|
||||
movies.forEach(movie => {
|
||||
const watchedMovie = watchedMovies.find(wm => wm.imdb_id === movie.imdb_id);
|
||||
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
||||
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||
const movieRow = document.createElement('tr');
|
||||
movieRow.innerHTML = `
|
||||
@@ -118,12 +455,9 @@ window.onload = async function () {
|
||||
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
|
||||
`;
|
||||
|
||||
// Set initial row color based on watched state
|
||||
if (watchedMovie) {
|
||||
movieRow.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Lighter light green with transparency
|
||||
} else {
|
||||
movieRow.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Lighter light red with transparency
|
||||
}
|
||||
// Use Bootstrap table state classes (matches Stats page styling better than custom backgrounds)
|
||||
movieRow.classList.toggle('table-success', Boolean(watchedMovie));
|
||||
movieRow.classList.toggle('table-danger', !watchedMovie);
|
||||
|
||||
movieTableBody.appendChild(movieRow);
|
||||
|
||||
@@ -141,12 +475,71 @@ window.onload = async function () {
|
||||
const watchedDateCell = document.getElementById(`watched-date-${movie.imdb_id}`);
|
||||
const row = event.target.closest('tr'); // Get the parent row
|
||||
|
||||
if (isChecked) {
|
||||
watchedDateCell.textContent = new Date().toLocaleString();
|
||||
row.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Apply light green with transparency
|
||||
} else {
|
||||
watchedDateCell.textContent = '';
|
||||
row.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Apply light red with transparency
|
||||
// If unchecking, show confirmation modal first
|
||||
if (!isChecked) {
|
||||
// Revert the checkbox temporarily until confirmed
|
||||
event.target.checked = true;
|
||||
|
||||
// Show confirmation modal
|
||||
const uncheckModal = new bootstrap.Modal(document.getElementById('uncheckConfirmModal'));
|
||||
const movieTitleEl = document.getElementById('uncheckMovieTitle');
|
||||
const confirmBtn = document.getElementById('confirmUncheckBtn');
|
||||
|
||||
if (movieTitleEl) movieTitleEl.textContent = movie.title;
|
||||
|
||||
// Remove any previous event listener to avoid duplicates
|
||||
const newConfirmBtn = confirmBtn.cloneNode(true);
|
||||
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
|
||||
|
||||
newConfirmBtn.addEventListener('click', async () => {
|
||||
uncheckModal.hide();
|
||||
|
||||
// Now actually uncheck
|
||||
event.target.checked = false;
|
||||
watchedDateCell.textContent = '';
|
||||
if (row) {
|
||||
row.classList.add('table-danger');
|
||||
row.classList.remove('table-success');
|
||||
}
|
||||
|
||||
await fetch('/api/movies/users/updateWatchedMovies', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ imdb_id: movie.imdb_id, isChecked: false })
|
||||
});
|
||||
|
||||
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (updatedWatchedMoviesRes.ok) {
|
||||
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
|
||||
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
|
||||
const updatedWatchedCount = watchedMoviesInYear.length;
|
||||
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
|
||||
updateProgressBar(updatedWatchedCount, totalMoviesCount);
|
||||
|
||||
const moviesLeft = totalMoviesCount - updatedWatchedCount;
|
||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
||||
}
|
||||
});
|
||||
|
||||
uncheckModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Checking a movie - no confirmation needed
|
||||
watchedDateCell.textContent = new Date().toLocaleString();
|
||||
if (row) {
|
||||
row.classList.add('table-success');
|
||||
row.classList.remove('table-danger');
|
||||
}
|
||||
|
||||
await fetch('/api/movies/users/updateWatchedMovies', {
|
||||
@@ -160,13 +553,16 @@ window.onload = async function () {
|
||||
|
||||
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (updatedWatchedMoviesRes.ok) {
|
||||
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
|
||||
const updatedWatchedCount = updatedWatchedMovies.length;
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
||||
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
|
||||
const updatedWatchedCount = watchedMoviesInYear.length;
|
||||
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
|
||||
updateProgressBar(updatedWatchedCount, totalMoviesCount);
|
||||
|
||||
// Recalculate and update movies per day
|
||||
@@ -181,6 +577,10 @@ window.onload = async function () {
|
||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
||||
|
||||
pageLoader.setSubtitle('Finalisation de l’affichage…');
|
||||
pageLoader.setProgress(92);
|
||||
pageLoader.done();
|
||||
|
||||
document.getElementById('log-off').addEventListener('click', () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
@@ -190,7 +590,8 @@ window.onload = async function () {
|
||||
const videoModal = document.getElementById("videoModal");
|
||||
const rewardVideo = document.getElementById("rewardVideo");
|
||||
videoModal.addEventListener("hidden.bs.modal", function () {
|
||||
if (!rewardVideo) return;
|
||||
rewardVideo.pause();
|
||||
rewardVideo.currentTime = 0;
|
||||
});
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
// Runs early (defer) to populate the navbar before page-specific JS.
|
||||
// Goal: avoid "late" header updates that look like flicker.
|
||||
(function () {
|
||||
function base64UrlToJson(b64url) {
|
||||
const s = String(b64url || '')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const padded = s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
return JSON.parse(atob(padded));
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token) {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length < 2) return null;
|
||||
try { return base64UrlToJson(parts[1]); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function isExpired(decoded) {
|
||||
const exp = Number(decoded?.exp);
|
||||
if (!Number.isFinite(exp) || exp <= 0) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return exp < now;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const decoded = decodeJwtPayload(token);
|
||||
if (!decoded) return;
|
||||
|
||||
// Fast-path redirect checks (match page scripts behavior).
|
||||
if (decoded.mustChangePassword && !/\/change-password\.html(\?|#|$)/.test(window.location.pathname)) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
}
|
||||
if (isExpired(decoded)) {
|
||||
try { localStorage.removeItem('auth_token'); } catch (_) {}
|
||||
if (!/\/index\.html$/.test(window.location.pathname) && window.location.pathname !== '/') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const name = decoded.name || '';
|
||||
const displayName = name;
|
||||
const nameTargets = document.querySelectorAll('[data-user-name]');
|
||||
if (nameTargets.length) {
|
||||
nameTargets.forEach((el) => {
|
||||
if (el && !el.textContent) el.textContent = displayName;
|
||||
});
|
||||
} else {
|
||||
const nameEl = document.getElementById('user-name');
|
||||
if (nameEl && !nameEl.textContent) {
|
||||
nameEl.textContent = displayName;
|
||||
}
|
||||
}
|
||||
|
||||
const adminLink = document.getElementById('admin-control-link');
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight the active page in the navbar.
|
||||
(function markActiveNavLink() {
|
||||
const currentFile = (String(window.location.pathname || '').split('/').pop() || 'index.html').toLowerCase();
|
||||
const links = document.querySelectorAll('nav.navbar .navbar-nav a.nav-link[href]');
|
||||
links.forEach((a) => {
|
||||
const rawHref = String(a.getAttribute('href') || '');
|
||||
const hrefFile = rawHref.split('#')[0].split('?')[0].split('/').pop().toLowerCase();
|
||||
const isCurrent = hrefFile && hrefFile === currentFile;
|
||||
a.classList.toggle('is-current', isCurrent);
|
||||
if (isCurrent) {
|
||||
a.setAttribute('aria-current', 'page');
|
||||
} else if (a.getAttribute('aria-current') === 'page') {
|
||||
a.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
+144
-20
@@ -1,29 +1,153 @@
|
||||
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||
if (localStorage.getItem('auth_token')) {
|
||||
// If the token is found, redirect to /movies.html
|
||||
window.location.href = '/movies.html';
|
||||
function decodeJwt(token) {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
}
|
||||
|
||||
async function applyActiveOscarYearToTitle() {
|
||||
const base = 'Pool Oscars';
|
||||
const current = String(document.title || base);
|
||||
const sep = ' - ';
|
||||
const idx = current.indexOf(sep);
|
||||
const suffix = idx >= 0 ? current.slice(idx) : '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
||||
const data = res.ok ? await res.json().catch(() => null) : null;
|
||||
const year = Number(data?.year);
|
||||
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
|
||||
} catch (_) {
|
||||
document.title = `${base}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire and forget.
|
||||
applyActiveOscarYearToTitle();
|
||||
|
||||
// Check setup status first
|
||||
async function checkSetupStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
|
||||
if (!res.ok) return { needsSetup: false };
|
||||
return await res.json();
|
||||
} catch (_) {
|
||||
return { needsSetup: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
const setupCard = document.getElementById('setup-card');
|
||||
const loginCard = document.getElementById('login-card');
|
||||
|
||||
// Check if setup is needed
|
||||
const status = await checkSetupStatus();
|
||||
|
||||
if (status.needsSetup) {
|
||||
// Show setup card, hide login card
|
||||
if (setupCard) setupCard.classList.remove('d-none');
|
||||
if (loginCard) loginCard.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle login form submission
|
||||
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
||||
// Check if the user is already logged in
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
try {
|
||||
const decoded = decodeJwt(existingToken);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
if (decoded.exp && decoded.exp < currentTime) {
|
||||
localStorage.removeItem('auth_token');
|
||||
} else if (decoded.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
} else {
|
||||
window.location.href = '/movies.html';
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the page
|
||||
initPage();
|
||||
|
||||
// Handle setup form submission
|
||||
const setupForm = document.getElementById('setup-form');
|
||||
if (setupForm) {
|
||||
setupForm.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const setupError = document.getElementById('setup-error');
|
||||
const name = document.getElementById('setup-name').value.trim();
|
||||
const email = document.getElementById('setup-email').value.trim();
|
||||
const password = document.getElementById('setup-password').value;
|
||||
const passwordConfirm = document.getElementById('setup-password-confirm').value;
|
||||
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
// Hide previous error
|
||||
if (setupError) setupError.classList.add('d-none');
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== passwordConfirm) {
|
||||
if (setupError) {
|
||||
setupError.textContent = 'Les mots de passe ne correspondent pas';
|
||||
setupError.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users/setup/admin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Save the token to localStorage
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
window.location.href = '/movies.html'; // Redirect to movies page on success
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`${error.message}`);
|
||||
|
||||
if (res.ok) {
|
||||
// Save the token and redirect
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
window.location.href = '/movies.html';
|
||||
} else {
|
||||
if (setupError) {
|
||||
setupError.textContent = data.message || 'Erreur lors de la création du compte';
|
||||
setupError.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (setupError) {
|
||||
setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
|
||||
setupError.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle login form submission
|
||||
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Save the token to localStorage
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
|
||||
if (data.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
} else {
|
||||
window.location.href = '/movies.html'; // Redirect to movies page on success
|
||||
}
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`${error.message}`);
|
||||
}
|
||||
});
|
||||
+640
-60
@@ -1,16 +1,410 @@
|
||||
window.onload = async function () {
|
||||
const targetDate = new Date('March 2, 2025');
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
let navResizeObserver = null;
|
||||
|
||||
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
|
||||
const preloadedOverlay = document.getElementById('page-loader-overlay');
|
||||
const overlay = preloadedOverlay || document.createElement('div');
|
||||
if (!preloadedOverlay) {
|
||||
overlay.className = 'page-loader-overlay';
|
||||
overlay.setAttribute('role', 'status');
|
||||
overlay.setAttribute('aria-live', 'polite');
|
||||
overlay.setAttribute('aria-busy', 'true');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">${title}</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Keep screen reader label up to date.
|
||||
const label = overlay.querySelector('.visually-hidden');
|
||||
if (label) label.textContent = title;
|
||||
}
|
||||
|
||||
const barEl = () => overlay.querySelector('#page-loader-bar');
|
||||
|
||||
function clampPct(p) {
|
||||
const n = Number(p);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function setProgress(p) {
|
||||
progress = clampPct(p);
|
||||
const bar = barEl();
|
||||
if (bar) bar.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
function setSubtitle(_) {
|
||||
// Intentionally no-op (we only show the bar now).
|
||||
}
|
||||
|
||||
function getNavHeightPx() {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (!nav) return 0;
|
||||
const rect = nav.getBoundingClientRect();
|
||||
const h = Number(rect?.height) || 0;
|
||||
return h > 0 ? Math.round(h) : 0;
|
||||
}
|
||||
|
||||
function updateOverlayTopOffset() {
|
||||
// Scope the offset to this overlay instance (no global CSS var).
|
||||
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
|
||||
}
|
||||
|
||||
function ensureMounted() {
|
||||
if (removed) return;
|
||||
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
|
||||
// we still need to compute the navbar offset. Otherwise it defaults to 0px
|
||||
// and the blur covers the header.
|
||||
updateOverlayTopOffset();
|
||||
document.body.classList.add('page-loading');
|
||||
|
||||
if (!overlay.isConnected) document.body.appendChild(overlay);
|
||||
|
||||
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
|
||||
if (!navResizeObserver) {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (nav && typeof ResizeObserver !== 'undefined') {
|
||||
try {
|
||||
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
|
||||
navResizeObserver.observe(nav);
|
||||
} catch (_) {
|
||||
navResizeObserver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideAndRemoveSoon() {
|
||||
if (removed) return;
|
||||
overlay.classList.add('page-loader-hide');
|
||||
overlay.setAttribute('aria-busy', 'false');
|
||||
document.body.classList.remove('page-loading');
|
||||
window.setTimeout(() => {
|
||||
removed = true;
|
||||
if (navResizeObserver) {
|
||||
try { navResizeObserver.disconnect(); } catch (_) {}
|
||||
navResizeObserver = null;
|
||||
}
|
||||
try { overlay.remove(); } catch (_) {}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function done() {
|
||||
setProgress(100);
|
||||
hideAndRemoveSoon();
|
||||
}
|
||||
|
||||
function fail(_) {
|
||||
// Don't block the UI on errors: let the page render its own error message.
|
||||
setProgress(Math.max(progress, 95));
|
||||
window.setTimeout(() => hideAndRemoveSoon(), 200);
|
||||
}
|
||||
|
||||
ensureMounted();
|
||||
setProgress(8);
|
||||
|
||||
return { setProgress, setSubtitle, done, fail };
|
||||
}
|
||||
|
||||
async function waitForImagesIn(containerEl, onProgress) {
|
||||
if (!containerEl) return;
|
||||
const imgs = Array.from(containerEl.querySelectorAll('img'));
|
||||
if (imgs.length === 0) return;
|
||||
|
||||
let resolved = 0;
|
||||
const total = imgs.length;
|
||||
const notify = () => {
|
||||
if (typeof onProgress === 'function') onProgress(resolved, total);
|
||||
};
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(
|
||||
imgs.map((img) => new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
resolved += 1;
|
||||
notify();
|
||||
resolve();
|
||||
};
|
||||
if (img.complete) return finish();
|
||||
img.addEventListener('load', finish, { once: true });
|
||||
img.addEventListener('error', finish, { once: true });
|
||||
}))
|
||||
),
|
||||
new Promise((resolve) => window.setTimeout(resolve, 7000)), // safety timeout
|
||||
]);
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async function () {
|
||||
const pageLoader = createPageLoader({
|
||||
title: 'Chargement des films',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
function formatClock(seconds) {
|
||||
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatRuntime(runtime) {
|
||||
// Convert "142 min" or "142" to "2h 22min" format
|
||||
if (!runtime || runtime === 'N/A') return '';
|
||||
const raw = String(runtime).trim();
|
||||
// Extract minutes from strings like "142 min", "142min", or just "142"
|
||||
const match = raw.match(/^(\d+)\s*min/i);
|
||||
const minutes = match ? parseInt(match[1], 10) : parseInt(raw, 10);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return raw;
|
||||
if (minutes < 60) return `${minutes}min`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h ${m}min` : `${h}h`;
|
||||
}
|
||||
|
||||
async function fetchPlaybackProgressByYear(year, token) {
|
||||
try {
|
||||
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return new Map();
|
||||
const rows = await res.json().catch(() => []);
|
||||
const map = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((r) => {
|
||||
const movieId = typeof r?.movieId === 'string' ? r.movieId : '';
|
||||
if (!movieId) return;
|
||||
const time = Number(r?.time);
|
||||
const duration = r?.duration === null || r?.duration === undefined ? null : Number(r.duration);
|
||||
map.set(movieId, {
|
||||
time: Number.isFinite(time) && time >= 0 ? time : 0,
|
||||
duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
} catch (_) {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoadError(el, message) {
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<div class="alert alert-danger my-3" role="alert">
|
||||
${message || 'Erreur lors du chargement.'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function normalizeRatingValue(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const rounded = Math.round(n);
|
||||
if (rounded < 1 || rounded > 5) return null;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
async function saveUserRating(movieId, rating, token) {
|
||||
try {
|
||||
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ rating }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json().catch(() => null);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserRating(movieId, token) {
|
||||
try {
|
||||
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json().catch(() => null);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStarUi(wrap, rating) {
|
||||
if (!wrap) return;
|
||||
const value = Number(rating);
|
||||
const user = Number.isFinite(value) ? value : null;
|
||||
if (wrap?.dataset) {
|
||||
wrap.dataset.currentRating = user ? String(user) : '';
|
||||
}
|
||||
wrap.querySelectorAll('.movie-star').forEach((btn) => {
|
||||
const starValue = Number(btn.getAttribute('data-value'));
|
||||
const isActive = user && starValue <= user;
|
||||
btn.classList.toggle('is-active', !!isActive);
|
||||
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function updateRatingTexts(cardEl, avgRating, ratingsCount, userRating) {
|
||||
if (!cardEl) return;
|
||||
const avgEl = cardEl.querySelector('[data-role="avg-rating"]');
|
||||
const userEl = cardEl.querySelector('[data-role="user-rating"]');
|
||||
const avg = Number.isFinite(Number(avgRating)) ? Number(avgRating) : null;
|
||||
const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0;
|
||||
const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null;
|
||||
|
||||
if (avgEl) {
|
||||
if (avg && count > 0) {
|
||||
avgEl.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
|
||||
avgEl.classList.remove('movie-rating-sub--empty');
|
||||
} else {
|
||||
avgEl.textContent = '';
|
||||
avgEl.classList.add('movie-rating-sub--empty');
|
||||
}
|
||||
}
|
||||
|
||||
if (userEl) {
|
||||
userEl.textContent = '';
|
||||
userEl.classList.add('movie-rating-sub--empty');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchActiveYear() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||
const data = await res.json();
|
||||
const year = Number(data?.year);
|
||||
return Number.isInteger(year) ? year : new Date().getFullYear();
|
||||
} catch (_) {
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOscarEffectiveDate(year) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
||||
const data = await res.json();
|
||||
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
||||
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
|
||||
} catch (_) {
|
||||
return `${year}-03-15`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocalNoonFromIsoDate(dateStr) {
|
||||
// Use local noon to avoid DST/midnight edge cases for "days left" math.
|
||||
const d = new Date(`${dateStr}T12:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
async function fetchMoviesSummary(year, token) {
|
||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function applySummaryToHeader(summary) {
|
||||
const lastUpdatedEl = document.getElementById('movies-last-updated');
|
||||
const ratioEl = document.getElementById('watched-ratio');
|
||||
|
||||
const lastUpdatedIso = summary?.lastUpdated;
|
||||
if (lastUpdatedEl) {
|
||||
if (lastUpdatedIso) {
|
||||
const d = new Date(lastUpdatedIso);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} else {
|
||||
lastUpdatedEl.textContent = '—';
|
||||
}
|
||||
} else {
|
||||
lastUpdatedEl.textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
const total = Number(summary?.totalMoviesCount) || 0;
|
||||
const watched = Number(summary?.watchedMoviesCount) || 0;
|
||||
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
|
||||
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
|
||||
}
|
||||
|
||||
const activeYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${activeYear}) - Films`;
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
// Render countdown immediately with a deterministic fallback (then refine from settings).
|
||||
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
|
||||
const timeLeftEl = document.getElementById('time-left');
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = fallbackDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
fallbackDate;
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(token.split('.')[1]));
|
||||
if (decoded.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
}
|
||||
const userName = decoded.name;
|
||||
document.getElementById('user-name').textContent = userName;
|
||||
if (decoded.admin) {
|
||||
const adminLink = document.getElementById('admin-control-link');
|
||||
if (adminLink) adminLink.classList.remove('d-none');
|
||||
}
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
if (decoded.exp && decoded.exp < currentTime) {
|
||||
@@ -25,62 +419,248 @@ window.onload = async function () {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
const res = await fetch('/api/movies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const movies = await res.json();
|
||||
|
||||
// Sort movies alphabetically by title
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
const watchedRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
let watchedMovies = [];
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
}
|
||||
|
||||
const moviesList = document.getElementById('movies-list');
|
||||
const totalMoviesCount = movies.length;
|
||||
const watchedMoviesCount = watchedMovies.length;
|
||||
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
||||
document.getElementById('watched-ratio').innerText = ratioText;
|
||||
pageLoader.setProgress(18);
|
||||
|
||||
movies.forEach(movie => {
|
||||
const isChecked = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
||||
const watchedMovie = watchedMovies.find(wm => wm.imdb_id === movie.imdb_id);
|
||||
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||
let watchedMoviesInYear = [];
|
||||
let progressByMovieId = new Map();
|
||||
|
||||
const movieDiv = document.createElement('div');
|
||||
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card');
|
||||
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
||||
movieDiv.innerHTML = `
|
||||
<div class="card">
|
||||
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''}
|
||||
<a href="${movie.vod_link}"><img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;"></a>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex justify-content-between align-items-center">
|
||||
${movie.title}
|
||||
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span>
|
||||
</h5>
|
||||
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p>
|
||||
<p class="card-text">${movie.description}</p>
|
||||
${isChecked ? `<p class="card-text"><strong>Regardé le:</strong> ${watchedDate}</p>` : ''}
|
||||
try {
|
||||
// Start summary fetch immediately so header can update ASAP.
|
||||
const summaryPromise = fetchMoviesSummary(activeYear, token)
|
||||
.then((data) => {
|
||||
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
|
||||
applySummaryToHeader(data);
|
||||
pageLoader.setProgress(32);
|
||||
return data;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the placeholders (—) if summary fails; movies list can still load.
|
||||
const lastUpdatedEl = document.getElementById('movies-last-updated');
|
||||
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
|
||||
const ratioEl = document.getElementById('watched-ratio');
|
||||
if (ratioEl) ratioEl.textContent = '—';
|
||||
return null;
|
||||
});
|
||||
|
||||
// Fetch progress in parallel (movies page uses it to show resume/progression).
|
||||
const progressPromise = fetchPlaybackProgressByYear(activeYear, token)
|
||||
.then((m) => { progressByMovieId = m instanceof Map ? m : new Map(); })
|
||||
.catch(() => { progressByMovieId = new Map(); });
|
||||
|
||||
pageLoader.setProgress(28);
|
||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const movies = await res.json();
|
||||
pageLoader.setProgress(55);
|
||||
// If summary is still in flight, wait for it before rendering watched banners.
|
||||
await Promise.all([summaryPromise, progressPromise]);
|
||||
pageLoader.setProgress(62);
|
||||
|
||||
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
const totalMoviesCount = movies.length;
|
||||
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||
document.getElementById('watched-ratio').innerText = ratioText;
|
||||
|
||||
if (moviesList) moviesList.innerHTML = '';
|
||||
|
||||
movies.forEach(movie => {
|
||||
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
||||
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
||||
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||
const cleanUrl = (v) => {
|
||||
const s = (typeof v === 'string' ? v.trim() : '');
|
||||
// Treat placeholders as "no link" so you never need to store '#'
|
||||
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
|
||||
return s;
|
||||
};
|
||||
const hasVideoSrc = !!cleanUrl(movie?.video_src);
|
||||
const hasEmbedSrc = !!cleanUrl(movie?.embed_src);
|
||||
const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
|
||||
const legacyUrl = cleanUrl(movie?.vod_link);
|
||||
const hasLegacy = !!legacyUrl;
|
||||
const movieId = movie && movie._id ? String(movie._id) : '';
|
||||
const userRatingAvg = Number(movie?.user_rating_avg);
|
||||
const userRatingCount = Number(movie?.user_rating_count) || 0;
|
||||
const userRating = Number(movie?.user_rating);
|
||||
const userRatingText =
|
||||
Number.isFinite(userRatingAvg) && userRatingAvg > 0
|
||||
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
|
||||
: '';
|
||||
const userRatingOwn =
|
||||
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
|
||||
const starsHtml = Array.from({ length: 5 }, (_, idx) => {
|
||||
const value = idx + 1;
|
||||
const isActive = Number.isFinite(userRating) && userRating >= value;
|
||||
return `<button type="button" class="movie-star${isActive ? ' is-active' : ''}" data-value="${value}" aria-label="${value} étoile${value > 1 ? 's' : ''}" aria-pressed="${isActive ? 'true' : 'false'}">★</button>`;
|
||||
}).join('');
|
||||
|
||||
// Navigation:
|
||||
// - Prefer in-app player when we have a non-legacy source
|
||||
// - If only legacy exists, open the legacy URL
|
||||
// - If there is no source at all, render as non-clickable (no "#")
|
||||
const playerUrl = (movie && movie._id && hasNewPlayerSource)
|
||||
? `/player.html?id=${encodeURIComponent(movie._id)}`
|
||||
: (hasLegacy ? legacyUrl : '');
|
||||
const isClickable = !!playerUrl;
|
||||
|
||||
// Progress: only show for in-app player sources (not legacy vod_link, not "no source").
|
||||
// Also avoid showing a meaningless "0:00" if they never really started.
|
||||
let progressHtml = '';
|
||||
if (hasNewPlayerSource && movieId) {
|
||||
const p = progressByMovieId.get(movieId);
|
||||
const time = Number(p?.time) || 0;
|
||||
const duration = p?.duration === null || p?.duration === undefined ? null : Number(p.duration);
|
||||
if (time > 1) {
|
||||
if (duration && Number.isFinite(duration) && duration > 0) {
|
||||
const pct = Math.max(0, Math.min(100, (time / duration) * 100));
|
||||
// Hide "resume/progress" once we're basically at the credits.
|
||||
// Backend uses 95% as the "credit roll" threshold too.
|
||||
if (pct < 95) {
|
||||
progressHtml = `
|
||||
<div class="mt-2">
|
||||
<div class="d-flex justify-content-between align-items-center text-muted small">
|
||||
<span>Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span></span>
|
||||
<span>${pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: ${pct.toFixed(2)}%;" aria-valuenow="${pct.toFixed(0)}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
progressHtml = `
|
||||
<div class="mt-2 text-muted small">
|
||||
Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const camBadge = movie?.cam ? '<span class="badge bg-warning text-dark position-absolute top-0 end-0 m-2 cam-badge">CAM</span>' : '';
|
||||
const posterBlock = `
|
||||
<div class="text-center pt-3 px-3 position-relative movie-poster-wrap">
|
||||
${camBadge}
|
||||
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
|
||||
</div>
|
||||
</div>`;
|
||||
moviesList.appendChild(movieDiv);
|
||||
});
|
||||
`;
|
||||
|
||||
const movieDiv = document.createElement('div');
|
||||
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
||||
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
||||
movieDiv.innerHTML = `
|
||||
<div class="card h-100 shadow-sm">
|
||||
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
|
||||
${
|
||||
isClickable
|
||||
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
|
||||
${posterBlock}
|
||||
</a>`
|
||||
: `${posterBlock}`
|
||||
}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="card-title mb-1">
|
||||
${
|
||||
isClickable
|
||||
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
|
||||
: `<span class="text-dark">${movie.title}</span>`
|
||||
}
|
||||
</h5>
|
||||
${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
|
||||
<div class="movie-rating-block mt-1">
|
||||
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movie-imdb-wrap">
|
||||
<div class="movie-imdb-block">
|
||||
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
|
||||
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
|
||||
<span class="imdb-icon" aria-hidden="true">IMDb</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="movie-rating-stars" role="group" aria-label="Noter ce film" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
|
||||
${starsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
|
||||
<div class="movie-info-box">
|
||||
<details class="movie-info-categories">
|
||||
<summary class="movie-info-summary">
|
||||
<span class="movie-info-summary-title">Catégories nominées</span>
|
||||
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="movie-info-category">${movie.category}</div>
|
||||
</details>
|
||||
<p class="movie-info-desc">${movie.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||
${progressHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
moviesList.appendChild(movieDiv);
|
||||
|
||||
const starsWrap = movieDiv.querySelector('.movie-rating-stars');
|
||||
if (starsWrap) {
|
||||
if (!token || !movieId) {
|
||||
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
|
||||
} else {
|
||||
starsWrap.addEventListener('click', async (event) => {
|
||||
const target = event.target;
|
||||
if (!target || !target.classList.contains('movie-star')) return;
|
||||
const value = normalizeRatingValue(target.getAttribute('data-value'));
|
||||
if (!value) return;
|
||||
|
||||
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
|
||||
const current = Number(starsWrap.dataset.currentRating || '');
|
||||
const shouldClear = current && current === value;
|
||||
const result = shouldClear
|
||||
? await deleteUserRating(movieId, token)
|
||||
: await saveUserRating(movieId, value, token);
|
||||
if (result) {
|
||||
updateStarUi(starsWrap, result?.userRating);
|
||||
updateRatingTexts(movieDiv, result?.averageRating, result?.ratingsCount, result?.userRating);
|
||||
}
|
||||
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.removeAttribute('disabled'));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pageLoader.setSubtitle('Finalisation de l’affichage…');
|
||||
pageLoader.setProgress(80);
|
||||
await waitForImagesIn(moviesList, (loaded, total) => {
|
||||
// Move 80% -> 98% as posters resolve
|
||||
const pct = 80 + (loaded / Math.max(1, total)) * 18;
|
||||
pageLoader.setProgress(pct);
|
||||
});
|
||||
pageLoader.done();
|
||||
} catch (e) {
|
||||
setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.');
|
||||
pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.');
|
||||
}
|
||||
|
||||
document.getElementById('log-off').addEventListener('click', function () {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -104,7 +684,7 @@ window.onload = async function () {
|
||||
|
||||
movieCards.forEach(card => {
|
||||
const imdbId = card.getAttribute('data-imdb-id');
|
||||
const isWatched = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === imdbId);
|
||||
const isWatched = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === imdbId);
|
||||
|
||||
if (showUnwatchedOnly && isWatched) {
|
||||
card.style.display = 'none';
|
||||
@@ -115,4 +695,4 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
filterMovies();
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,912 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
let navResizeObserver = null;
|
||||
|
||||
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
|
||||
const preloadedOverlay = document.getElementById('page-loader-overlay');
|
||||
const overlay = preloadedOverlay || document.createElement('div');
|
||||
if (!preloadedOverlay) {
|
||||
overlay.className = 'page-loader-overlay';
|
||||
overlay.setAttribute('role', 'status');
|
||||
overlay.setAttribute('aria-live', 'polite');
|
||||
overlay.setAttribute('aria-busy', 'true');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">${title}</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Keep screen reader label up to date.
|
||||
const label = overlay.querySelector('.visually-hidden');
|
||||
if (label) label.textContent = title;
|
||||
}
|
||||
|
||||
const barEl = () => overlay.querySelector('#page-loader-bar');
|
||||
|
||||
function clampPct(p) {
|
||||
const n = Number(p);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function setProgress(p) {
|
||||
progress = clampPct(p);
|
||||
const bar = barEl();
|
||||
if (bar) bar.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
function setSubtitle(_) {
|
||||
// Intentionally no-op (we only show the bar now).
|
||||
}
|
||||
|
||||
function getNavHeightPx() {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (!nav) return 0;
|
||||
const rect = nav.getBoundingClientRect();
|
||||
const h = Number(rect?.height) || 0;
|
||||
return h > 0 ? Math.round(h) : 0;
|
||||
}
|
||||
|
||||
function updateOverlayTopOffset() {
|
||||
// Scope the offset to this overlay instance (no global CSS var).
|
||||
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
|
||||
}
|
||||
|
||||
function ensureMounted() {
|
||||
if (removed) return;
|
||||
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
|
||||
// we still need to compute the navbar offset. Otherwise it defaults to 0px
|
||||
// and the blur covers the header.
|
||||
updateOverlayTopOffset();
|
||||
document.body.classList.add('page-loading');
|
||||
|
||||
if (!overlay.isConnected) document.body.appendChild(overlay);
|
||||
|
||||
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
|
||||
if (!navResizeObserver) {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (nav && typeof ResizeObserver !== 'undefined') {
|
||||
try {
|
||||
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
|
||||
navResizeObserver.observe(nav);
|
||||
} catch (_) {
|
||||
navResizeObserver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideAndRemoveSoon() {
|
||||
if (removed) return;
|
||||
overlay.classList.add('page-loader-hide');
|
||||
overlay.setAttribute('aria-busy', 'false');
|
||||
document.body.classList.remove('page-loading');
|
||||
window.setTimeout(() => {
|
||||
removed = true;
|
||||
if (navResizeObserver) {
|
||||
try { navResizeObserver.disconnect(); } catch (_) {}
|
||||
navResizeObserver = null;
|
||||
}
|
||||
try { overlay.remove(); } catch (_) {}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function done() {
|
||||
setProgress(100);
|
||||
hideAndRemoveSoon();
|
||||
}
|
||||
|
||||
function fail(_) {
|
||||
// Don't block the UI on errors: let the page render its own error message.
|
||||
setProgress(Math.max(progress, 95));
|
||||
window.setTimeout(() => hideAndRemoveSoon(), 200);
|
||||
}
|
||||
|
||||
ensureMounted();
|
||||
setProgress(8);
|
||||
|
||||
return { setProgress, setSubtitle, done, fail };
|
||||
}
|
||||
|
||||
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(pageLoader) {
|
||||
try {
|
||||
// Use active year if not set
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes picks`;
|
||||
}
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(18);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(40);
|
||||
|
||||
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>
|
||||
`;
|
||||
if (pageLoader) pageLoader.done();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(60);
|
||||
|
||||
renderCategories();
|
||||
await loadMyPicks(pageLoader);
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(85);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
|
||||
if (pageLoader) pageLoader.fail();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyPicks(pageLoader) {
|
||||
try {
|
||||
if (pageLoader) pageLoader.setProgress(70);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(80);
|
||||
|
||||
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
|
||||
window.addEventListener('DOMContentLoaded', async function() {
|
||||
const pageLoader = createPageLoader({
|
||||
title: 'Chargement des picks',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
try {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||
|
||||
pageLoader.setProgress(12);
|
||||
await fetchCategories(pageLoader);
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
pageLoader.done();
|
||||
} catch (err) {
|
||||
console.error('Error during initial load:', err);
|
||||
pageLoader.fail();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,75 @@
|
||||
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||
if (localStorage.getItem('auth_token')) {
|
||||
// If the token is found, redirect to /movies.html
|
||||
window.location.href = '/movies.html';
|
||||
function decodeJwt(token) {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
}
|
||||
|
||||
async function applyActiveOscarYearToTitle() {
|
||||
const base = 'Pool Oscars';
|
||||
const current = String(document.title || base);
|
||||
const sep = ' - ';
|
||||
const idx = current.indexOf(sep);
|
||||
const suffix = idx >= 0 ? current.slice(idx) : '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
||||
const data = res.ok ? await res.json().catch(() => null) : null;
|
||||
const year = Number(data?.year);
|
||||
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
|
||||
} catch (_) {
|
||||
document.title = `${base}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire and forget.
|
||||
applyActiveOscarYearToTitle();
|
||||
|
||||
// Check setup status
|
||||
async function checkSetupStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
|
||||
if (!res.ok) return { needsSetup: false };
|
||||
return await res.json();
|
||||
} catch (_) {
|
||||
return { needsSetup: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
const setupNeededCard = document.getElementById('setup-needed-card');
|
||||
const registerCard = document.getElementById('register-card');
|
||||
|
||||
// Check if setup is needed
|
||||
const status = await checkSetupStatus();
|
||||
|
||||
if (status.needsSetup) {
|
||||
// Show setup-needed banner, hide registration form
|
||||
if (setupNeededCard) setupNeededCard.classList.remove('d-none');
|
||||
if (registerCard) registerCard.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||
const existingToken = localStorage.getItem('auth_token');
|
||||
if (existingToken) {
|
||||
try {
|
||||
const decoded = decodeJwt(existingToken);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
if (decoded.exp && decoded.exp < currentTime) {
|
||||
localStorage.removeItem('auth_token');
|
||||
} else if (decoded.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
} else {
|
||||
window.location.href = '/movies.html';
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the page
|
||||
initPage();
|
||||
|
||||
// Handle registration form submission
|
||||
document.getElementById('register-form').addEventListener('submit', async function (e) {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Theme toggle with system preference fallback.
|
||||
(function () {
|
||||
const STORAGE_KEY = 'theme_preference';
|
||||
const LAST_KEY = 'theme_last_applied';
|
||||
const THEME_ATTR = 'data-theme';
|
||||
const SOURCE_ATTR = 'data-theme-source';
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const API_ENDPOINT = '/api/users/theme';
|
||||
|
||||
function normalizeTheme(value) {
|
||||
return value === 'light' || value === 'dark' ? value : null;
|
||||
}
|
||||
|
||||
function getSystemTheme() {
|
||||
return mediaQuery.matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function setTheme(theme, source) {
|
||||
document.documentElement.setAttribute(THEME_ATTR, theme);
|
||||
document.documentElement.setAttribute(SOURCE_ATTR, source);
|
||||
try {
|
||||
sessionStorage.setItem(LAST_KEY, theme);
|
||||
} catch (_) {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
updateToggleUi(theme, source);
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
try {
|
||||
return localStorage.getItem('auth_token');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferenceToServer(themePreference) {
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
await fetch(API_ENDPOINT, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ themePreference: themePreference ?? null })
|
||||
});
|
||||
} catch (_) {
|
||||
// Ignore network errors to avoid blocking UI.
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPreferenceFromServer() {
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(API_ENDPOINT, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const pref = normalizeTheme(data?.themePreference);
|
||||
const currentPref = normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||
const changed = pref !== currentPref;
|
||||
if (changed) {
|
||||
if (pref) {
|
||||
localStorage.setItem(STORAGE_KEY, pref);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
applyStoredOrSystem();
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore fetch errors.
|
||||
}
|
||||
}
|
||||
|
||||
function applyStoredOrSystem() {
|
||||
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||
let last = null;
|
||||
try {
|
||||
last = normalizeTheme(sessionStorage.getItem(LAST_KEY));
|
||||
} catch (_) {
|
||||
last = null;
|
||||
}
|
||||
const theme = stored || last || getSystemTheme();
|
||||
const source = stored ? 'user' : (last ? 'session' : 'system');
|
||||
setTheme(theme, source);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||
const current = stored || getSystemTheme();
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
setTheme(next, 'user');
|
||||
savePreferenceToServer(next);
|
||||
}
|
||||
|
||||
function resetToSystem() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
applyStoredOrSystem();
|
||||
savePreferenceToServer(null);
|
||||
}
|
||||
|
||||
function updateToggleUi(theme, source) {
|
||||
const buttons = document.querySelectorAll('[data-theme-toggle]');
|
||||
const isDark = theme === 'dark';
|
||||
buttons.forEach((button) => {
|
||||
button.setAttribute('aria-checked', String(isDark));
|
||||
button.dataset.theme = theme;
|
||||
const label = isDark ? 'Mode sombre' : 'Mode clair';
|
||||
const sourceLabel = source === 'system' ? ' (système)' : '';
|
||||
button.setAttribute('title', `${label}${sourceLabel} — Shift+clic pour système`);
|
||||
});
|
||||
}
|
||||
|
||||
applyStoredOrSystem();
|
||||
syncPreferenceFromServer();
|
||||
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
|
||||
if (!stored) {
|
||||
applyStoredOrSystem();
|
||||
}
|
||||
});
|
||||
|
||||
function bindToggleButtons() {
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
|
||||
if (button.dataset.themeBound === 'true') return;
|
||||
button.dataset.themeBound = 'true';
|
||||
button.addEventListener('click', (event) => {
|
||||
if (event.shiftKey) {
|
||||
resetToSystem();
|
||||
return;
|
||||
}
|
||||
toggleTheme();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindToggleButtons);
|
||||
} else {
|
||||
bindToggleButtons();
|
||||
}
|
||||
})();
|
||||
+722
-47
@@ -1,11 +1,344 @@
|
||||
window.onload = async function () {
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
let navResizeObserver = null;
|
||||
|
||||
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
|
||||
const preloadedOverlay = document.getElementById('page-loader-overlay');
|
||||
const overlay = preloadedOverlay || document.createElement('div');
|
||||
if (!preloadedOverlay) {
|
||||
overlay.className = 'page-loader-overlay';
|
||||
overlay.setAttribute('role', 'status');
|
||||
overlay.setAttribute('aria-live', 'polite');
|
||||
overlay.setAttribute('aria-busy', 'true');
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">${title}</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Keep screen reader label up to date.
|
||||
const label = overlay.querySelector('.visually-hidden');
|
||||
if (label) label.textContent = title;
|
||||
}
|
||||
|
||||
const barEl = () => overlay.querySelector('#page-loader-bar');
|
||||
|
||||
function clampPct(p) {
|
||||
const n = Number(p);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function setProgress(p) {
|
||||
progress = clampPct(p);
|
||||
const bar = barEl();
|
||||
if (bar) bar.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
function setSubtitle(_) {
|
||||
// Intentionally no-op (we only show the bar now).
|
||||
}
|
||||
|
||||
function getNavHeightPx() {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (!nav) return 0;
|
||||
const rect = nav.getBoundingClientRect();
|
||||
const h = Number(rect?.height) || 0;
|
||||
return h > 0 ? Math.round(h) : 0;
|
||||
}
|
||||
|
||||
function updateOverlayTopOffset() {
|
||||
// Scope the offset to this overlay instance (no global CSS var).
|
||||
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
|
||||
}
|
||||
|
||||
function ensureMounted() {
|
||||
if (removed) return;
|
||||
|
||||
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
|
||||
// we still need to compute the navbar offset. Otherwise it defaults to 0px
|
||||
// and the blur covers the header.
|
||||
updateOverlayTopOffset();
|
||||
document.body.classList.add('page-loading');
|
||||
|
||||
if (!overlay.isConnected) document.body.appendChild(overlay);
|
||||
|
||||
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
|
||||
if (!navResizeObserver) {
|
||||
const nav = document.querySelector('nav.navbar');
|
||||
if (nav && typeof ResizeObserver !== 'undefined') {
|
||||
try {
|
||||
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
|
||||
navResizeObserver.observe(nav);
|
||||
} catch (_) {
|
||||
navResizeObserver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideAndRemoveSoon() {
|
||||
if (removed) return;
|
||||
overlay.classList.add('page-loader-hide');
|
||||
overlay.setAttribute('aria-busy', 'false');
|
||||
document.body.classList.remove('page-loading');
|
||||
window.setTimeout(() => {
|
||||
removed = true;
|
||||
if (navResizeObserver) {
|
||||
try { navResizeObserver.disconnect(); } catch (_) {}
|
||||
navResizeObserver = null;
|
||||
}
|
||||
try { overlay.remove(); } catch (_) {}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function done() {
|
||||
setProgress(100);
|
||||
hideAndRemoveSoon();
|
||||
}
|
||||
|
||||
function fail(_) {
|
||||
// Don't block the UI on errors: let the page render its own error message.
|
||||
setProgress(Math.max(progress, 95));
|
||||
window.setTimeout(() => hideAndRemoveSoon(), 200);
|
||||
}
|
||||
|
||||
ensureMounted();
|
||||
setProgress(8);
|
||||
|
||||
return { setProgress, setSubtitle, done, fail };
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async function () {
|
||||
const pageLoader = createPageLoader({
|
||||
title: 'Chargement des statistiques',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
let winnersCache = null;
|
||||
let completionsCache = null;
|
||||
|
||||
async function fetchActiveYear() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||
const data = await res.json();
|
||||
const year = Number(data?.year);
|
||||
return Number.isInteger(year) ? year : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWinners() {
|
||||
try {
|
||||
const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error('Failed to fetch winners');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return Array.isArray(data?.winners) ? data.winners : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCompletions() {
|
||||
try {
|
||||
const res = await fetch('/api/users/completions', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch completions');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data && typeof data === 'object' ? data : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWinners(winners) {
|
||||
const tbody = document.getElementById('winners-table-body');
|
||||
if (!tbody) return;
|
||||
const list = Array.isArray(winners) ? winners : [];
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">Aucun gagnant défini.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by year (supports ties)
|
||||
const byYear = new Map();
|
||||
list.forEach((w) => {
|
||||
const y = Number(w?.year || 0);
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 3000) return;
|
||||
const arr = byYear.get(String(y)) || [];
|
||||
arr.push({
|
||||
year: y,
|
||||
name: w?.name || '(utilisateur supprimé)',
|
||||
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
|
||||
});
|
||||
byYear.set(String(y), arr);
|
||||
});
|
||||
|
||||
const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a);
|
||||
tbody.innerHTML = '';
|
||||
|
||||
years.forEach((year) => {
|
||||
const winnersForYear = byYear.get(String(year)) || [];
|
||||
const sorted = winnersForYear.slice().sort((a, b) => {
|
||||
const ap = a.points === null || Number.isNaN(a.points) ? null : a.points;
|
||||
const bp = b.points === null || Number.isNaN(b.points) ? null : b.points;
|
||||
if (ap === null && bp !== null) return 1;
|
||||
if (ap !== null && bp === null) return -1;
|
||||
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
|
||||
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const tdYear = document.createElement('td');
|
||||
tdYear.className = 'fw-semibold';
|
||||
tdYear.textContent = String(year);
|
||||
if (sorted.length > 1) {
|
||||
const small = document.createElement('div');
|
||||
small.className = 'text-muted small';
|
||||
small.textContent = 'égalité';
|
||||
tdYear.appendChild(document.createElement('br'));
|
||||
tdYear.appendChild(small);
|
||||
}
|
||||
|
||||
const tdWinners = document.createElement('td');
|
||||
sorted.forEach((w, idx) => {
|
||||
const row = document.createElement('div');
|
||||
if (idx > 0) row.classList.add('mt-1');
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'fw-semibold';
|
||||
name.textContent = w?.name || '(utilisateur supprimé)';
|
||||
row.appendChild(name);
|
||||
|
||||
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
|
||||
if (pts !== null) {
|
||||
const ptsSpan = document.createElement('span');
|
||||
ptsSpan.className = 'text-muted';
|
||||
ptsSpan.textContent = ` — ${pts} pts`;
|
||||
row.appendChild(ptsSpan);
|
||||
}
|
||||
|
||||
tdWinners.appendChild(row);
|
||||
});
|
||||
|
||||
tr.appendChild(tdYear);
|
||||
tr.appendChild(tdWinners);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCompletions(completions) {
|
||||
const tbody = document.getElementById('completers-table-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!completions || typeof completions !== 'object') {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">Impossible de charger les finisseurs 100%.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const years = Array.isArray(completions?.years) ? completions.years : [];
|
||||
const totals = completions?.totals && typeof completions.totals === 'object' ? completions.totals : {};
|
||||
const byYear = completions?.completersByYear && typeof completions.completersByYear === 'object' ? completions.completersByYear : {};
|
||||
|
||||
if (!years.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">Aucune année trouvée.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedYears = years
|
||||
.map((y) => Number(y))
|
||||
.filter((y) => Number.isInteger(y))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
tbody.innerHTML = '';
|
||||
sortedYears.forEach((y) => {
|
||||
const yearStr = String(y);
|
||||
const total = Number(totals?.[yearStr] ?? 0);
|
||||
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const tdYear = document.createElement('td');
|
||||
tdYear.className = 'fw-semibold';
|
||||
tdYear.textContent = yearStr;
|
||||
|
||||
const tdTotal = document.createElement('td');
|
||||
tdTotal.textContent = String(Number.isFinite(total) ? total : 0);
|
||||
|
||||
const tdNames = document.createElement('td');
|
||||
if (!completers.length) {
|
||||
tdNames.className = 'text-muted';
|
||||
tdNames.textContent = '—';
|
||||
} else {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'd-flex flex-wrap gap-1';
|
||||
completers.forEach((u, idx) => {
|
||||
const rank = idx + 1;
|
||||
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge bg-light text-dark border';
|
||||
// Show rank, medal (if top 3), and name
|
||||
const rankPrefix = medal ? `${medal} ` : `#${rank} `;
|
||||
// Format completion date if available
|
||||
let dateStr = '';
|
||||
if (u?.completedAt) {
|
||||
const date = new Date(u.completedAt);
|
||||
dateStr = ` (${date.toLocaleDateString('fr-CA')})`;
|
||||
}
|
||||
badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`;
|
||||
wrap.appendChild(badge);
|
||||
});
|
||||
tdNames.appendChild(wrap);
|
||||
}
|
||||
|
||||
tr.appendChild(tdYear);
|
||||
tr.appendChild(tdTotal);
|
||||
tr.appendChild(tdNames);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
|
||||
if (decoded.mustChangePassword) {
|
||||
window.location.href = '/change-password.html';
|
||||
return;
|
||||
}
|
||||
const userName = decoded.name;
|
||||
document.getElementById('user-name').textContent = userName;
|
||||
if (decoded.admin) {
|
||||
const adminLink = document.getElementById('admin-control-link');
|
||||
if (adminLink) adminLink.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Verify JWT expiration
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
@@ -24,71 +357,413 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
try {
|
||||
const statsRes = await fetch('/api/users/stats', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
pageLoader.setProgress(12);
|
||||
|
||||
// Modal UI (Films regardés)
|
||||
const moviesModalEl = document.getElementById('moviesModal');
|
||||
const movieGridEl = document.getElementById('movie-grid');
|
||||
const moviesSummaryEl = document.getElementById('movies-summary');
|
||||
const moviesEmptyEl = document.getElementById('movies-empty');
|
||||
const moviesModalTitleEl = document.getElementById('moviesModalLabel');
|
||||
|
||||
let currentModalUserName = '';
|
||||
let currentModalMovies = [];
|
||||
|
||||
function safeStr(v) {
|
||||
return (v === null || v === undefined) ? '' : String(v);
|
||||
}
|
||||
|
||||
function parseMaybeNumber(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function formatWatchedDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
try {
|
||||
return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch (_) {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePosterUrl(poster) {
|
||||
const s = safeStr(poster).trim();
|
||||
if (!s || s.toUpperCase() === 'N/A') return '';
|
||||
return s;
|
||||
}
|
||||
|
||||
function sortMoviesRecent(list) {
|
||||
const arr = Array.isArray(list) ? list.slice() : [];
|
||||
// watchedDate desc, fallback title
|
||||
arr.sort((a, b) => {
|
||||
const at = a?.watchedDate ? new Date(a.watchedDate).getTime() : 0;
|
||||
const bt = b?.watchedDate ? new Date(b.watchedDate).getTime() : 0;
|
||||
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at;
|
||||
return safeStr(a?.title).localeCompare(safeStr(b?.title), 'fr', { sensitivity: 'base' });
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
|
||||
function renderMoviesModal() {
|
||||
if (!movieGridEl || !moviesSummaryEl || !moviesEmptyEl) return;
|
||||
|
||||
const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : []));
|
||||
|
||||
const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0;
|
||||
moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film';
|
||||
|
||||
movieGridEl.innerHTML = '';
|
||||
if (!sorted.length) {
|
||||
moviesEmptyEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
moviesEmptyEl.classList.add('d-none');
|
||||
|
||||
sorted.forEach((movie) => {
|
||||
const movieId = safeStr(movie?.movieId).trim();
|
||||
const imdbId = safeStr(movie?.imdb_id).trim();
|
||||
const title = safeStr(movie?.title).trim() || '(sans titre)';
|
||||
const category = safeStr(movie?.category).trim();
|
||||
const ratingRaw = safeStr(movie?.rating).trim();
|
||||
const rating = parseMaybeNumber(ratingRaw);
|
||||
const watchedLabel = formatWatchedDate(movie?.watchedDate);
|
||||
const posterUrl = normalizePosterUrl(movie?.poster);
|
||||
|
||||
const cleanUrl = (v) => {
|
||||
const s = (typeof v === 'string' ? v.trim() : '');
|
||||
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
|
||||
return s;
|
||||
};
|
||||
|
||||
// Click behavior (requested):
|
||||
// - If vod_link is set => go to it
|
||||
// - Else if any playable source exists => go to player
|
||||
// - Else => not clickable
|
||||
const vodLink = cleanUrl(movie?.vod_link);
|
||||
const hasPlayableSource = !!cleanUrl(movie?.video_src) || !!cleanUrl(movie?.embed_src) || !!cleanUrl(movie?.video_file);
|
||||
const isClickable = !!vodLink || (!!movieId && hasPlayableSource);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'stats-movie-item';
|
||||
if (isClickable) {
|
||||
item.classList.add('stats-movie-item--clickable');
|
||||
item.setAttribute('role', 'link');
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`);
|
||||
}
|
||||
|
||||
const poster = document.createElement('img');
|
||||
poster.className = 'stats-movie-poster';
|
||||
poster.alt = title;
|
||||
if (posterUrl) {
|
||||
poster.src = posterUrl;
|
||||
} else {
|
||||
poster.classList.add('stats-movie-poster--empty');
|
||||
// Keep src empty; browser will not request anything.
|
||||
poster.src = '';
|
||||
}
|
||||
poster.addEventListener('error', () => {
|
||||
poster.classList.add('stats-movie-poster--empty');
|
||||
try { poster.removeAttribute('src'); } catch (_) {}
|
||||
});
|
||||
|
||||
function openIfPossible(evt) {
|
||||
if (!isClickable) return;
|
||||
// Don't hijack clicks inside controls (IMDb button, details summary, etc.)
|
||||
const target = evt?.target;
|
||||
if (target && typeof target.closest === 'function') {
|
||||
if (target.closest('a, button, summary, details')) return;
|
||||
}
|
||||
|
||||
if (vodLink) {
|
||||
// Use a new tab (keeps the app state intact)
|
||||
try {
|
||||
const w = window.open(vodLink, '_blank', 'noopener,noreferrer');
|
||||
if (!w) window.location.href = vodLink;
|
||||
} catch (_) {
|
||||
window.location.href = vodLink;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (movieId && hasPlayableSource) {
|
||||
window.location.href = `/player.html?id=${encodeURIComponent(movieId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isClickable) {
|
||||
item.addEventListener('click', openIfPossible);
|
||||
item.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openIfPossible(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const main = document.createElement('div');
|
||||
main.className = 'flex-grow-1';
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'd-flex align-items-start justify-content-between gap-2';
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'stats-movie-title';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'text-nowrap';
|
||||
|
||||
if (imdbId) {
|
||||
const imdbLink = document.createElement('a');
|
||||
imdbLink.className = 'btn btn-outline-dark btn-sm stats-movie-imdb';
|
||||
imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`;
|
||||
imdbLink.target = '_blank';
|
||||
imdbLink.rel = 'noopener noreferrer';
|
||||
imdbLink.setAttribute('aria-label', 'Voir sur IMDb');
|
||||
|
||||
const imdbIcon = document.createElement('span');
|
||||
imdbIcon.className = 'imdb-icon';
|
||||
imdbIcon.setAttribute('aria-hidden', 'true');
|
||||
imdbIcon.textContent = 'IMDb';
|
||||
|
||||
imdbLink.appendChild(imdbIcon);
|
||||
right.appendChild(imdbLink);
|
||||
}
|
||||
|
||||
topRow.appendChild(titleEl);
|
||||
topRow.appendChild(right);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'stats-movie-meta';
|
||||
|
||||
if (rating !== null && rating > 0) {
|
||||
const r = document.createElement('span');
|
||||
r.className = 'text-muted';
|
||||
r.textContent = `⭐ ${rating.toFixed(1)}`;
|
||||
meta.appendChild(r);
|
||||
} else if (ratingRaw) {
|
||||
const r = document.createElement('span');
|
||||
r.className = 'text-muted';
|
||||
r.textContent = `⭐ ${ratingRaw}`;
|
||||
meta.appendChild(r);
|
||||
}
|
||||
|
||||
if (watchedLabel) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'text-muted';
|
||||
d.textContent = `Vu le ${watchedLabel}`;
|
||||
meta.appendChild(d);
|
||||
}
|
||||
|
||||
main.appendChild(topRow);
|
||||
if (meta.childNodes.length) main.appendChild(meta);
|
||||
|
||||
// Optional: keep long strings (like category) out of the main layout.
|
||||
if (category) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'stats-movie-details';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'Détails';
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'stats-movie-details-body text-muted small';
|
||||
body.textContent = category;
|
||||
details.appendChild(body);
|
||||
|
||||
main.appendChild(details);
|
||||
}
|
||||
|
||||
item.appendChild(poster);
|
||||
item.appendChild(main);
|
||||
movieGridEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function openMoviesModalForUser(userStat) {
|
||||
currentModalUserName = safeStr(userStat?.name).trim();
|
||||
currentModalMovies = Array.isArray(userStat?.watchedMovies) ? userStat.watchedMovies : [];
|
||||
|
||||
if (moviesModalTitleEl) {
|
||||
const count = currentModalMovies.length;
|
||||
moviesModalTitleEl.textContent = currentModalUserName
|
||||
? `Films regardés — ${currentModalUserName} (${count})`
|
||||
: `Films regardés (${count})`;
|
||||
}
|
||||
|
||||
renderMoviesModal();
|
||||
|
||||
if (moviesModalEl) {
|
||||
const modal = new bootstrap.Modal(moviesModalEl);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
if (moviesModalEl) {
|
||||
moviesModalEl.addEventListener('shown.bs.modal', () => {
|
||||
// no controls to focus
|
||||
});
|
||||
}
|
||||
|
||||
// Show loading states immediately (avoid "blank" panels)
|
||||
const table = document.querySelector('.user-table');
|
||||
const statsError = document.getElementById('stats-error');
|
||||
if (table) table.classList.add('d-none');
|
||||
if (statsError) statsError.classList.add('d-none');
|
||||
|
||||
pageLoader.setProgress(20);
|
||||
const activeYear = await fetchActiveYear();
|
||||
if (activeYear) {
|
||||
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
|
||||
// Keep the main heading clean; show the year in the "Classement" tab instead.
|
||||
const h2 = document.querySelector('h2');
|
||||
if (h2) h2.textContent = 'Statistiques des utilisateurs';
|
||||
const badge = document.getElementById('stats-active-year-badge');
|
||||
if (badge) {
|
||||
badge.textContent = String(activeYear);
|
||||
badge.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
|
||||
pageLoader.setProgress(32);
|
||||
const [statsRes, winners, completions] = await Promise.all([
|
||||
fetch(statsUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
cache: 'no-cache',
|
||||
}),
|
||||
fetchWinners(),
|
||||
fetchCompletions(),
|
||||
]);
|
||||
|
||||
winnersCache = winners;
|
||||
completionsCache = completions;
|
||||
renderWinners(winnersCache);
|
||||
renderCompletions(completionsCache);
|
||||
|
||||
if (!statsRes.ok) {
|
||||
throw new Error('Failed to fetch user stats');
|
||||
}
|
||||
|
||||
pageLoader.setProgress(70);
|
||||
const stats = await statsRes.json();
|
||||
const userTableBody = document.getElementById('user-table-body');
|
||||
const table = document.querySelector('.user-table');
|
||||
const spinner = document.getElementById('loading-spinner');
|
||||
|
||||
// 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), then by last watched date (earlier = better rank)
|
||||
stats.sort((a, b) => {
|
||||
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
|
||||
if (pointsDiff !== 0) return pointsDiff;
|
||||
// Tiebreaker: earlier completion wins (user who finished first ranks higher)
|
||||
const aDate = a.lastWatchedAt ? new Date(a.lastWatchedAt).getTime() : Infinity;
|
||||
const bDate = b.lastWatchedAt ? new Date(b.lastWatchedAt).getTime() : Infinity;
|
||||
if (aDate !== bDate) return aDate - bDate;
|
||||
// Final tiebreaker: alphabetical
|
||||
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
// 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');
|
||||
userRow.innerHTML = `
|
||||
<td>
|
||||
<a href="#" class="user-link" data-movies='${JSON.stringify(userStat.watchedMovies)}'>${userStat.name}</a>
|
||||
</td>
|
||||
<td>${userStat.watchedCount}</td>
|
||||
<td>${userStat.watchedRatio}</td>
|
||||
`;
|
||||
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 = '#';
|
||||
link.className = 'user-link';
|
||||
link.textContent = userStat.name;
|
||||
link.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
openMoviesModalForUser(userStat);
|
||||
});
|
||||
nameTd.appendChild(link);
|
||||
|
||||
const countTd = document.createElement('td');
|
||||
const pointsConfig = userStat.pointsConfig || { pointsPerMovie: 1 };
|
||||
countTd.innerHTML = `${userStat.watchedCount ?? 0} <small class="text-muted">(${userStat.moviePoints || 0} pts)</small>`;
|
||||
|
||||
const picksTd = document.createElement('td');
|
||||
const pickPointsConfig = userStat.pointsConfig || { pointsPerCorrectPick: 1 };
|
||||
picksTd.innerHTML = `${userStat.correctPicks || 0} <small class="text-muted">(${userStat.pickPoints || 0} pts)</small>`;
|
||||
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(picksTd);
|
||||
userRow.appendChild(pointsTd);
|
||||
userTableBody.appendChild(userRow);
|
||||
});
|
||||
|
||||
// Hide spinner and show table after loading
|
||||
spinner.style.display = 'none';
|
||||
table.style.display = 'table';
|
||||
// Show table after loading
|
||||
if (table) table.classList.remove('d-none');
|
||||
if (statsError) statsError.classList.add('d-none');
|
||||
pageLoader.setProgress(92);
|
||||
|
||||
// Add event listener for user links
|
||||
document.querySelectorAll('.user-link').forEach(link => {
|
||||
link.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
const datasetMovies = this.dataset.movies;
|
||||
console.log(datasetMovies);
|
||||
const movies = JSON.parse(datasetMovies);
|
||||
|
||||
// Clear the current movie list in the modal
|
||||
const movieList = document.getElementById('movie-list');
|
||||
movieList.innerHTML = '';
|
||||
|
||||
// Populate the modal with the movies the user has watched
|
||||
movies.forEach(movie => {
|
||||
const movieItem = document.createElement('li');
|
||||
movieItem.classList.add('list-group-item');
|
||||
movieItem.textContent = movie.title;
|
||||
movieList.appendChild(movieItem);
|
||||
});
|
||||
|
||||
// Show the modal
|
||||
const modal = new bootstrap.Modal(document.getElementById('moviesModal'));
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
pageLoader.done();
|
||||
} catch (error) {
|
||||
console.error('Error loading user statistics:', error);
|
||||
const table = document.querySelector('.user-table');
|
||||
const statsError = document.getElementById('stats-error');
|
||||
if (table) table.classList.add('d-none');
|
||||
if (statsError) {
|
||||
statsError.textContent = "Impossible de charger le classement pour le moment.";
|
||||
statsError.classList.remove('d-none');
|
||||
}
|
||||
// Still attempt to render these sections even if stats fail (best effort)
|
||||
try {
|
||||
if (winnersCache === null) winnersCache = await fetchWinners();
|
||||
renderWinners(winnersCache);
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (completionsCache === null) completionsCache = await fetchCompletions();
|
||||
renderCompletions(completionsCache);
|
||||
} catch (_) {}
|
||||
pageLoader.fail();
|
||||
}
|
||||
|
||||
// Log-off functionality
|
||||
@@ -96,4 +771,4 @@ window.onload = async function () {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
+77
-64
@@ -3,9 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars 2025 - Films</title>
|
||||
<title>Pool Oscars - Films</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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;
|
||||
@@ -19,42 +22,9 @@
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* Style for the 'WATCHED' banner */
|
||||
.watched-banner {
|
||||
background-color: green;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Centering checkbox and making card look better */
|
||||
.movie-card {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#movies-list {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Style for the filter button */
|
||||
#filter-button {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
/* Style for the ratio display */
|
||||
.ratio {
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.navbar-toggler {
|
||||
border-color: white; /* Make the border white */
|
||||
}
|
||||
@@ -64,14 +34,25 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<body class="bg-light page-loading">
|
||||
|
||||
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
|
||||
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
|
||||
<a class="navbar-brand" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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>
|
||||
@@ -79,48 +60,80 @@
|
||||
<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="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</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 -->
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
<span id="user-name" class="navbar-text me-3"></span>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2>Films</h2>
|
||||
<div class="container mt-4 pb-5">
|
||||
<!-- Summary card (match Statistiques/Checklist structure) -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<div class="text-muted small">
|
||||
Dernière mise à jour des films: <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
|
||||
</div>
|
||||
<div id="watched-ratio" class="fw-semibold">—</div>
|
||||
<div class="text-muted small">
|
||||
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2 mt-3">
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
|
||||
</div>
|
||||
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Displaying last updated vod_links -->
|
||||
<div class="alert alert-warning fade show" role="alert">
|
||||
Dernière mise à jour des films: <strong>09-02-2025</strong>
|
||||
</div>
|
||||
|
||||
<!-- Displaying the ratio of watched movies -->
|
||||
<div id="watched-ratio" class="ratio">
|
||||
<!-- This will display the ratio -->
|
||||
</div>
|
||||
|
||||
<h3 class="fs-6">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3>
|
||||
<h3 class="fs-6 mb-0">
|
||||
|
||||
</h3>
|
||||
<br>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
|
||||
</div>
|
||||
<div id="movies-list" class="row">
|
||||
<!-- Movies will be displayed here -->
|
||||
<div id="movies-list" class="row g-3 mt-1">
|
||||
<div class="col-12">
|
||||
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./js/app_movies.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<!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>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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 page-loading">
|
||||
|
||||
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
|
||||
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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">Stats</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 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4 pb-5">
|
||||
<div id="alert-container"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" 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>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
|
||||
<script src="./js/app_picks.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- IMPORTANT: the player may use ?token=... for cross-origin video streaming -->
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Pool Oscars</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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");
|
||||
}
|
||||
|
||||
.player-wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.player-surface {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
.meta-muted {
|
||||
color: rgba(0,0,0,0.65);
|
||||
}
|
||||
.badge-src {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: .25rem .4rem .25rem .6rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.quality-label {
|
||||
font-size: .8rem;
|
||||
color: rgba(0,0,0,0.6);
|
||||
font-weight: 600;
|
||||
}
|
||||
.quality-pill {
|
||||
display: inline-flex;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.quality-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(0,0,0,0.65);
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: .25rem .6rem;
|
||||
line-height: 1.1;
|
||||
transition: background .2s ease, color .2s ease;
|
||||
}
|
||||
.quality-btn.is-active {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
.quality-btn:focus-visible {
|
||||
outline: 2px solid rgba(13,110,253,.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.user-rating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.rating-stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .15rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.star-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: .15rem .2rem;
|
||||
}
|
||||
.star-btn.is-active {
|
||||
color: #f4b400;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .star-btn {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .star-btn.is-active {
|
||||
color: #f4b400;
|
||||
}
|
||||
.star-btn:focus-visible {
|
||||
outline: 2px solid rgba(13,110,253,.35);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#user-rating-meta {
|
||||
color: rgba(0,0,0,0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Progress indicator: icon-first, minimal text */
|
||||
.progress-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
.progress-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: rgba(0,0,0,.65);
|
||||
flex: 0 0 1rem;
|
||||
}
|
||||
.progress-icon--spinner {
|
||||
border: 2px solid rgba(0,0,0,.22);
|
||||
border-top-color: rgba(0,0,0,.65);
|
||||
border-radius: 50%;
|
||||
animation: progressSpin .8s linear infinite;
|
||||
}
|
||||
.progress-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
.progress-text {
|
||||
opacity: .8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.progress-text.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
@keyframes progressSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light page-loading">
|
||||
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
|
||||
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Black banner with buttons and user info -->
|
||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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">Stats</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 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4 pb-4 player-wrap">
|
||||
<div id="alert" class="alert alert-danger d-none" role="alert"></div>
|
||||
|
||||
<!-- Header / metadata -->
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||
<div>
|
||||
<h2 id="movie-title" class="mb-1">Chargement…</h2>
|
||||
<div class="text-muted small">
|
||||
<span id="movie-category"></span><span id="movie-rating"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column gap-2 align-items-center align-items-lg-center">
|
||||
<div id="quality-toggle" class="d-none quality-toggle">
|
||||
<span class="quality-label">Qualité</span>
|
||||
<div class="quality-pill" role="group" aria-label="Qualité">
|
||||
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true">Haute</button>
|
||||
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false">Basse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="user-rating" class="user-rating">
|
||||
<div class="rating-stars" role="group" aria-label="Noter ce film">
|
||||
<button type="button" class="star-btn" data-value="1" aria-label="1 étoile" aria-pressed="false">★</button>
|
||||
<button type="button" class="star-btn" data-value="2" aria-label="2 étoiles" aria-pressed="false">★</button>
|
||||
<button type="button" class="star-btn" data-value="3" aria-label="3 étoiles" aria-pressed="false">★</button>
|
||||
<button type="button" class="star-btn" data-value="4" aria-label="4 étoiles" aria-pressed="false">★</button>
|
||||
<button type="button" class="star-btn" data-value="5" aria-label="5 étoiles" aria-pressed="false">★</button>
|
||||
</div>
|
||||
<div id="user-rating-meta" class="text-muted small">—</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin-only debug/status (Source + Progress) -->
|
||||
<div id="player-admin-status" class="d-none d-flex flex-column gap-2">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span class="badge bg-secondary">Source</span>
|
||||
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<span class="badge bg-secondary">Progress</span>
|
||||
<span id="progress-status" class="text-muted small progress-indicator">
|
||||
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
|
||||
<span id="progress-status-text" class="progress-text">—</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player surface -->
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-body p-0">
|
||||
<div class="player-surface">
|
||||
<video id="video" class="d-none" controls playsinline></video>
|
||||
<iframe
|
||||
id="embed"
|
||||
class="d-none"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer class="footer--player">
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- HLS support (m3u8) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
|
||||
<script src="./js/app_player.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+38
-3
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars 2025 - Enregistrement</title>
|
||||
<title>Pool Oscars - Enregistrement</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
</head>
|
||||
@@ -16,11 +17,45 @@
|
||||
}
|
||||
</style>
|
||||
<body class="bg-light">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="container d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="card p-4" style="max-width: 400px;">
|
||||
<!-- Setup needed banner (hidden by default) -->
|
||||
<div id="setup-needed-card" class="card p-4 d-none" style="max-width: 400px;">
|
||||
<div class="text-center mb-4">
|
||||
<img src="img/logo.png" style="width: 80%";>
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Configuration requise</strong><br>
|
||||
L'application doit d'abord être configurée par un administrateur.
|
||||
</div>
|
||||
<a href="/" class="btn btn-primary w-100">Aller à la page de configuration</a>
|
||||
</div>
|
||||
|
||||
<!-- Normal registration card -->
|
||||
<div id="register-card" class="card p-4" style="max-width: 400px;">
|
||||
<div class="text-center mb-4">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<form id="register-form">
|
||||
<div class="mb-3">
|
||||
|
||||
+201
-34
@@ -3,8 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pool Oscars 2025 - Statistiques des utilisateurs</title>
|
||||
<title>Pool Oscars - Statistiques des utilisateurs</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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;
|
||||
@@ -20,26 +24,91 @@
|
||||
.user-table {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.ratio {
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.navbar-toggler {
|
||||
border-color: white; /* Make the border 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");
|
||||
}
|
||||
|
||||
/* Stats tabs */
|
||||
.stats-tabs .nav-link {
|
||||
font-weight: 400;
|
||||
}
|
||||
.stats-tabs {
|
||||
border-bottom: 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.stats-tab-panel {
|
||||
padding-top: 0;
|
||||
background-color: var(--app-surface);
|
||||
padding: 0.75rem;
|
||||
margin-top: -1px;
|
||||
border: 1px solid var(--app-border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
.stats-tab-panel {
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
#statsTabs .nav-link.active {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
top: 1px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
#statsTabsContent > .tab-pane.active {
|
||||
position: relative;
|
||||
}
|
||||
#statsTabsContent > .tab-pane.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: calc(100% + 2px);
|
||||
margin-left: -1px;
|
||||
background: var(--app-surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
#statsTabsContent > .tab-pane.fade::before {
|
||||
background: transparent;
|
||||
}
|
||||
.stats-tab-panel > .tab-pane:not(#pane-ranking) {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
#pane-ranking .user-table {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.stats-tab-panel .table,
|
||||
.stats-tab-panel .table th,
|
||||
.stats-tab-panel .table td {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
html[data-theme="dark"] .stats-tabs .nav-link.active {
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<body class="bg-light page-loading">
|
||||
|
||||
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
|
||||
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="page-loader-card page-loader-card--baronly">
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
|
||||
<a class="navbar-brand" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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>
|
||||
@@ -47,59 +116,157 @@
|
||||
<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="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</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 -->
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||
<span id="user-name" class="navbar-text me-3"></span>
|
||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<h2>Statistiques des utilisateurs</h2>
|
||||
<div class="container mt-4">
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs stats-tabs" id="statsTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
|
||||
Classement
|
||||
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Année active"></span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
|
||||
Gagnants 🏆
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false">
|
||||
100%
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Spinner shown while loading -->
|
||||
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
<div class="tab-content stats-tab-panel" id="statsTabsContent">
|
||||
<!-- Ranking tab -->
|
||||
<div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0">
|
||||
<div id="stats-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
|
||||
<!-- Table for users and movies watched -->
|
||||
<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" 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">
|
||||
<!-- User rows will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Winners tab -->
|
||||
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 110px;">Année</th>
|
||||
<th scope="col">Gagnant(s)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="winners-table-body">
|
||||
<tr>
|
||||
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 100% completers tab -->
|
||||
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
|
||||
<div class="small text-muted mb-2 d-flex align-items-center gap-2">
|
||||
<span class="badge bg-light text-dark border" aria-hidden="true">i</span>
|
||||
<span>Utilisateurs ayant terminé 100% (par année)</span>
|
||||
</div>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 110px;">Année</th>
|
||||
<th scope="col" style="width: 140px;">Total films</th>
|
||||
<th scope="col">Noms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="completers-table-body">
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table for users and movies watched -->
|
||||
<table class="table user-table" style="display: none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Total des films regardés (pts)</th>
|
||||
<th scope="col">Ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table-body">
|
||||
<!-- User rows will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal for displaying movies watched -->
|
||||
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="movie-list" class="list-group">
|
||||
<!-- Movie titles will be inserted here -->
|
||||
</ul>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div id="movies-summary" class="text-muted small"></div>
|
||||
|
||||
<div id="movies-empty" class="text-muted d-none">Aucun film à afficher.</div>
|
||||
<div id="movie-grid" class="stats-movies-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./js/app_user-stat.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,138 @@
|
||||
<!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>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<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" 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>
|
||||
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
|
||||
<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">Stats</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 gap-2 navbar-user">
|
||||
<span id="user-name" class="navbar-text" data-user-name></span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="M4.93 4.93l1.41 1.41"></path>
|
||||
<path d="M17.66 17.66l1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="M4.93 19.07l1.41-1.41"></path>
|
||||
<path d="M17.66 6.34l1.41-1.41"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="theme-toggle-thumb" aria-hidden="true"></span>
|
||||
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4 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>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
|
||||
<script src="./js/app_view-picks.js"></script>
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Python video server (Range streaming + JWT + Mongo)
|
||||
|
||||
This is a small standalone server that mirrors the app’s `/api/video/:id` behavior:
|
||||
|
||||
- Looks up `movies._id == :id` in MongoDB and reads the `video_file` field
|
||||
- Streams the file from `VIDEO_FILES_DIR` with **HTTP Range** support (seeking works)
|
||||
- Auth via the same JWT (`Authorization: Bearer ...`), plus a `video_auth` cookie for `<video>`
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python -m pip install -r python_video_server/requirements.txt
|
||||
export MONGO_URI="..."
|
||||
export MONGO_DB_NAME="..." # optional; required if MONGO_URI doesn't include /<dbName>
|
||||
export JWT_SECRET="..."
|
||||
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
|
||||
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
|
||||
|
||||
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
|
||||
or
|
||||
python3 -m uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 --ssl-certfile python_video_server/certs/config/live/[site]/fullchain.pem --ssl-keyfile python_video_server/certs/config/live/[site]/privkey.pem
|
||||
```
|
||||
|
||||
## Extra environment variables (optional)
|
||||
|
||||
```bash
|
||||
# CORS (needed when the main app is on a different origin)
|
||||
export CORS_ALLOW_ORIGINS="https://your-app.example.com" # default: "*"
|
||||
|
||||
# Cookie behavior for /api/video/session
|
||||
export VIDEO_COOKIE_SAMESITE="Lax" # Lax|Strict|None (default: Lax)
|
||||
|
||||
# Mongo TLS / connectivity troubleshooting (Atlas, odd hosts, etc.)
|
||||
export MONGO_TLS_CA_FILE="/path/to/ca.pem" # prefer a valid CA bundle
|
||||
export MONGO_TLS_FORCE_TLS12="1" # force TLS 1.2 if TLS 1.3 breaks
|
||||
export MONGO_TLS_INSECURE="1" # NOT recommended; debug only
|
||||
export MONGO_SERVER_SELECTION_TIMEOUT_MS="8000"
|
||||
export MONGO_CONNECT_TIMEOUT_MS="8000"
|
||||
export MONGO_SOCKET_TIMEOUT_MS="20000"
|
||||
```
|
||||
|
||||
## Video auth / usage
|
||||
|
||||
`GET /api/video/{movieId}` accepts auth in three ways (in priority order):
|
||||
|
||||
1) `video_auth` cookie (best for browser `<video>` tags)
|
||||
2) `Authorization: Bearer <token>` header
|
||||
3) `?token=<token>` query param (debug only; tokens in URLs leak via logs/referrers)
|
||||
|
||||
### Browser `<video>` (cookie flow)
|
||||
|
||||
Because HTML `<video>` cannot send an `Authorization` header, the pattern is:
|
||||
|
||||
1. Your JS calls `POST /api/video/session` with `Authorization: Bearer <token>`
|
||||
2. The server responds `204` and sets a `video_auth` cookie (scoped to `/api/video`)
|
||||
3. Your `<video>` loads `/api/video/{movieId}` and the cookie is used automatically
|
||||
|
||||
Example (curl):
|
||||
|
||||
```bash
|
||||
# 1) exchange header token -> cookie
|
||||
curl -i -X POST "https://video.example.com/api/video/session" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
|
||||
# 2) then the <video> element can simply use:
|
||||
# https://video.example.com/api/video/<movieId>
|
||||
```
|
||||
|
||||
Cross-site note (frontend on a different origin):
|
||||
- Set `CORS_ALLOW_ORIGINS` to your exact app origin (not `*`) and ensure your frontend uses `credentials: "include"` on the `/api/video/session` request.
|
||||
- If the video host is cross-site, you’ll usually need `VIDEO_COOKIE_SAMESITE=None` **and** HTTPS, otherwise browsers may block the cookie.
|
||||
|
||||
### Non-browser clients (header flow)
|
||||
|
||||
```bash
|
||||
curl -I "https://video.example.com/api/video/<movieId>" \
|
||||
-H "Authorization: Bearer $JWT"
|
||||
```
|
||||
|
||||
### Token in URL (debug flow)
|
||||
|
||||
```bash
|
||||
curl -I "https://video.example.com/api/video/<movieId>?token=$JWT"
|
||||
```
|
||||
|
||||
### Seeking / Range requests (example)
|
||||
|
||||
```bash
|
||||
curl -i "https://video.example.com/api/video/<movieId>" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-H "Range: bytes=0-1023"
|
||||
```
|
||||
|
||||
## HTTPS (optional)
|
||||
|
||||
You can run behind a reverse proxy/ingress, or run `uvicorn` with TLS directly:
|
||||
|
||||
```bash
|
||||
uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
|
||||
--ssl-certfile /path/to/fullchain.pem \
|
||||
--ssl-keyfile /path/to/privkey.pem
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If you’re behind a proxy, forward `X-Forwarded-Proto: https` so `/api/video/session` can set `Secure` cookies correctly.
|
||||
|
||||
## Let’s Encrypt certs (python-only helper)
|
||||
|
||||
This repo includes `python_video_server/letsencrypt.py`, a wrapper around the **certbot Python package** (no system `certbot` needed).
|
||||
|
||||
Certificates (and certbot state) are stored under:
|
||||
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
|
||||
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
|
||||
|
||||
You can override the storage directory with `LETSENCRYPT_BASE_DIR` (optional).
|
||||
|
||||
### Issue a cert (HTTP-01 standalone, easiest)
|
||||
|
||||
Requirements:
|
||||
- DNS `A/AAAA` records point to this host
|
||||
- Port **80** reachable from the internet
|
||||
- You can bind port 80 (often requires `sudo`)
|
||||
|
||||
```bash
|
||||
python -m pip install -r python_video_server/requirements.txt
|
||||
|
||||
# staging by default (recommended first)
|
||||
sudo -E python -m python_video_server.letsencrypt certonly \
|
||||
--email [email protected] \
|
||||
--domains example.com,www.example.com
|
||||
|
||||
# production (real cert)
|
||||
sudo -E python -m python_video_server.letsencrypt certonly \
|
||||
--email [email protected] \
|
||||
--domains example.com,www.example.com \
|
||||
--production
|
||||
```
|
||||
|
||||
Then run uvicorn with those certs:
|
||||
|
||||
```bash
|
||||
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
|
||||
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
|
||||
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
|
||||
```
|
||||
|
||||
### Issue a cert (DNS-01 manual TXT, no port 80)
|
||||
|
||||
Use this if you cannot expose/bind port 80. Certbot will prompt you to create a TXT record.
|
||||
|
||||
```bash
|
||||
python -m pip install -r python_video_server/requirements.txt
|
||||
|
||||
python -m python_video_server.letsencrypt certonly \
|
||||
--challenge dns \
|
||||
--email [email protected] \
|
||||
--domains example.com,www.example.com \
|
||||
--production
|
||||
```
|
||||
|
||||
### Renew certs
|
||||
|
||||
```bash
|
||||
# dry-run (safe)
|
||||
sudo -E python -m python_video_server.letsencrypt renew --dry-run
|
||||
|
||||
# real renewal
|
||||
sudo -E python -m python_video_server.letsencrypt renew --production
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /api/video/session`
|
||||
Sets `video_auth` cookie (send `Authorization: Bearer <token>`). Returns `204 No Content`.
|
||||
|
||||
- `GET|HEAD /api/video/{movieId}`
|
||||
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,423 @@
|
||||
"""
|
||||
Let’s Encrypt certificate issuance/renewal using Python-only certbot.
|
||||
|
||||
Why this exists:
|
||||
- You already run the server with `uvicorn`.
|
||||
- Uvicorn can serve HTTPS when given cert/key PEM files.
|
||||
- The missing piece is obtaining and renewing a trusted certificate.
|
||||
|
||||
This module wraps the *certbot Python package* (no system `certbot` binary required).
|
||||
|
||||
Typical usage (standalone HTTP-01, binds to port 80):
|
||||
sudo -E python -m python_video_server.letsencrypt certonly \
|
||||
--email [email protected] \
|
||||
--domains example.com,www.example.com \
|
||||
--production
|
||||
|
||||
If you *cannot* bind port 80 (shared hosting, no root, port already in use),
|
||||
use DNS-01 instead (manual TXT record):
|
||||
python3 -m python_video_server.letsencrypt certonly \
|
||||
--challenge dns \
|
||||
--email [email protected] \
|
||||
--domains example.com,www.example.com \
|
||||
--production
|
||||
|
||||
Then run uvicorn with:
|
||||
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
|
||||
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
|
||||
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
|
||||
LE_PRODUCTION_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
LE_STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
||||
|
||||
def _default_base_dir() -> Path:
|
||||
# Keep certbot state inside the repo by default (and gitignored).
|
||||
# This mirrors certbot's default directory structure:
|
||||
# - config/live/<domain>/{fullchain.pem,privkey.pem,...}
|
||||
# - config/renewal/<domain>.conf
|
||||
# - work/*
|
||||
# - logs/*
|
||||
return (Path(__file__).resolve().parent / "certs").resolve()
|
||||
|
||||
|
||||
def _split_domains(domains_csv: str) -> List[str]:
|
||||
domains = [d.strip() for d in (domains_csv or "").split(",") if d.strip()]
|
||||
# Deduplicate while preserving order
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for d in domains:
|
||||
if d not in seen:
|
||||
out.append(d)
|
||||
seen.add(d)
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_dirs(base_dir: Path) -> tuple[Path, Path, Path]:
|
||||
config_dir = (base_dir / "config").resolve()
|
||||
work_dir = (base_dir / "work").resolve()
|
||||
logs_dir = (base_dir / "logs").resolve()
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir, work_dir, logs_dir
|
||||
|
||||
|
||||
def _ensure_dns_hook_scripts(work_dir: Path) -> tuple[Path, Path]:
|
||||
"""
|
||||
Create small executable hook scripts used by certbot's --manual DNS flow.
|
||||
|
||||
Certbot runs these hooks as external executables; we generate them under the
|
||||
certbot work dir so no special permissions are needed.
|
||||
"""
|
||||
|
||||
auth_hook = (work_dir / "certbot_dns_auth_hook.py").resolve()
|
||||
cleanup_hook = (work_dir / "certbot_dns_cleanup_hook.py").resolve()
|
||||
|
||||
auth_contents = """#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
def _open_tty():
|
||||
# Certbot may capture stdout/stderr of hooks; /dev/tty forces user-visible IO
|
||||
# when an interactive terminal exists.
|
||||
try:
|
||||
return open("/dev/tty", "r+", encoding="utf-8", buffering=1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def main() -> int:
|
||||
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
|
||||
validation = os.environ.get("CERTBOT_VALIDATION", "").strip()
|
||||
if not domain or not validation:
|
||||
print("Missing CERTBOT_DOMAIN/CERTBOT_VALIDATION in environment.", file=sys.stderr, flush=True)
|
||||
return 2
|
||||
|
||||
record_name = f"_acme-challenge.{domain}"
|
||||
msg = (
|
||||
"\\n=== DNS-01 challenge required ===\\n"
|
||||
"Create/Update this TXT record:\\n"
|
||||
f" Name: {record_name}\\n"
|
||||
" Type: TXT\\n"
|
||||
f" Value: {validation}\\n"
|
||||
"\\nAfter the record is published and has propagated, press Enter to continue.\\n"
|
||||
"> "
|
||||
)
|
||||
|
||||
tty = _open_tty()
|
||||
if tty is not None:
|
||||
tty.write(msg)
|
||||
tty.flush()
|
||||
try:
|
||||
tty.readline()
|
||||
except KeyboardInterrupt:
|
||||
tty.write("\\nAborted.\\n")
|
||||
tty.flush()
|
||||
return 130
|
||||
finally:
|
||||
try:
|
||||
tty.close()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Fallback: best-effort stdout + either wait or read stdin if interactive.
|
||||
print(msg, end="", flush=True)
|
||||
if not sys.stdin.isatty():
|
||||
wait_s = int(os.environ.get("LE_DNS_WAIT_SECONDS", "300"))
|
||||
print(f"\\n(non-interactive stdin detected; waiting {wait_s}s then continuing...)", flush=True)
|
||||
time.sleep(max(0, wait_s))
|
||||
return 0
|
||||
try:
|
||||
input("")
|
||||
except KeyboardInterrupt:
|
||||
print("\\nAborted.", file=sys.stderr, flush=True)
|
||||
return 130
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
"""
|
||||
|
||||
cleanup_contents = """#!/usr/bin/env python3
|
||||
import os
|
||||
|
||||
def _open_tty():
|
||||
try:
|
||||
return open("/dev/tty", "w", encoding="utf-8", buffering=1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def main() -> int:
|
||||
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
|
||||
if not domain:
|
||||
return 0
|
||||
msg = "\\nDNS-01 cleanup:\\nYou may now remove the TXT record: _acme-challenge.%s\\n" % domain
|
||||
tty = _open_tty()
|
||||
if tty is not None:
|
||||
tty.write(msg)
|
||||
tty.flush()
|
||||
try:
|
||||
tty.close()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
print(msg, end="", flush=True)
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
"""
|
||||
|
||||
auth_hook.write_text(auth_contents, encoding="utf-8")
|
||||
cleanup_hook.write_text(cleanup_contents, encoding="utf-8")
|
||||
os.chmod(auth_hook, 0o755)
|
||||
os.chmod(cleanup_hook, 0o755)
|
||||
|
||||
return auth_hook, cleanup_hook
|
||||
|
||||
|
||||
def _run_certbot(argv: Iterable[str]) -> int:
|
||||
try:
|
||||
# certbot is a Python package dependency (installed via pip).
|
||||
# Importing here keeps the server import path clean.
|
||||
import certbot.main # type: ignore
|
||||
except Exception as e:
|
||||
raise SystemExit(
|
||||
"Missing dependency: certbot is not installed.\n"
|
||||
"Install it with:\n"
|
||||
" python -m pip install -r python_video_server/requirements.txt\n"
|
||||
f"\nOriginal import error: {e!r}"
|
||||
)
|
||||
return int(certbot.main.main(list(argv)))
|
||||
|
||||
|
||||
def _common_certbot_flags(
|
||||
*,
|
||||
email: str,
|
||||
domains: List[str],
|
||||
base_dir: Path,
|
||||
production: bool,
|
||||
non_interactive: bool = True,
|
||||
) -> List[str]:
|
||||
if not email.strip():
|
||||
raise SystemExit("--email is required")
|
||||
if not domains:
|
||||
raise SystemExit("--domains is required (comma-separated)")
|
||||
|
||||
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
|
||||
|
||||
args: List[str] = [
|
||||
"--agree-tos",
|
||||
"--email",
|
||||
email.strip(),
|
||||
"--config-dir",
|
||||
str(config_dir),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
"--logs-dir",
|
||||
str(logs_dir),
|
||||
# Keep key type modern; certbot defaults may vary by version.
|
||||
"--key-type",
|
||||
"ecdsa",
|
||||
]
|
||||
if non_interactive:
|
||||
args.insert(0, "--non-interactive")
|
||||
|
||||
if production:
|
||||
args += ["--server", LE_PRODUCTION_DIRECTORY_URL]
|
||||
else:
|
||||
args += ["--server", LE_STAGING_DIRECTORY_URL]
|
||||
|
||||
for d in domains:
|
||||
args += ["-d", d]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def cmd_certonly(args: argparse.Namespace) -> int:
|
||||
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
|
||||
domains = _split_domains(args.domains)
|
||||
production = bool(args.production)
|
||||
|
||||
challenge = (args.challenge or "http").strip().lower()
|
||||
if challenge not in {"http", "dns"}:
|
||||
raise SystemExit("--challenge must be one of: http, dns")
|
||||
|
||||
if challenge == "dns":
|
||||
# DNS-01 works without binding any local ports; suitable for shared hosting / no root.
|
||||
#
|
||||
# Default: use certbot's built-in interactive manual prompt (most reliable).
|
||||
# Optional: --dns-hooks to use our hook scripts (useful for automation).
|
||||
certbot_args = [
|
||||
"certonly",
|
||||
"--manual",
|
||||
"--preferred-challenges",
|
||||
"dns",
|
||||
"--manual-public-ip-logging-ok",
|
||||
]
|
||||
|
||||
if bool(args.dns_hooks):
|
||||
_, work_dir, _ = _ensure_dirs(base_dir)
|
||||
auth_hook, cleanup_hook = _ensure_dns_hook_scripts(work_dir)
|
||||
certbot_args += [
|
||||
"--manual-auth-hook",
|
||||
str(auth_hook),
|
||||
"--manual-cleanup-hook",
|
||||
str(cleanup_hook),
|
||||
]
|
||||
certbot_args += _common_certbot_flags(
|
||||
email=args.email,
|
||||
domains=domains,
|
||||
base_dir=base_dir,
|
||||
production=production,
|
||||
non_interactive=True,
|
||||
)
|
||||
else:
|
||||
# IMPORTANT: manual DNS prompt requires interactive mode.
|
||||
certbot_args += _common_certbot_flags(
|
||||
email=args.email,
|
||||
domains=domains,
|
||||
base_dir=base_dir,
|
||||
production=production,
|
||||
non_interactive=False,
|
||||
)
|
||||
else:
|
||||
certbot_args = [
|
||||
"certonly",
|
||||
"--standalone",
|
||||
# HTTP-01 is the simplest/most compatible for typical VPS setups.
|
||||
"--preferred-challenges",
|
||||
"http",
|
||||
# If port 80 is already in use, certbot can still succeed behind a reverse proxy
|
||||
# using `--webroot`, but this wrapper defaults to standalone by design.
|
||||
"--http-01-port",
|
||||
str(int(args.http_01_port)),
|
||||
*_common_certbot_flags(
|
||||
email=args.email,
|
||||
domains=domains,
|
||||
base_dir=base_dir,
|
||||
production=production,
|
||||
non_interactive=True,
|
||||
),
|
||||
]
|
||||
|
||||
if args.force_renewal:
|
||||
certbot_args.append("--force-renewal")
|
||||
|
||||
if args.test_cert:
|
||||
certbot_args.append("--test-cert")
|
||||
|
||||
return _run_certbot(certbot_args)
|
||||
|
||||
|
||||
def cmd_renew(args: argparse.Namespace) -> int:
|
||||
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
|
||||
production = bool(args.production)
|
||||
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
|
||||
|
||||
certbot_args: List[str] = [
|
||||
"renew",
|
||||
"--non-interactive",
|
||||
"--config-dir",
|
||||
str(config_dir),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
"--logs-dir",
|
||||
str(logs_dir),
|
||||
"--key-type",
|
||||
"ecdsa",
|
||||
]
|
||||
|
||||
certbot_args += ["--server", LE_PRODUCTION_DIRECTORY_URL if production else LE_STAGING_DIRECTORY_URL]
|
||||
|
||||
if args.force_renewal:
|
||||
certbot_args.append("--force-renewal")
|
||||
if args.dry_run:
|
||||
certbot_args.append("--dry-run")
|
||||
|
||||
return _run_certbot(certbot_args)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="python -m python_video_server.letsencrypt",
|
||||
description="Python-only Let’s Encrypt cert issuance/renewal (wraps certbot package).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--base-dir",
|
||||
default=os.environ.get("LETSENCRYPT_BASE_DIR", ""),
|
||||
help="Where certbot state is stored (default: python_video_server/certs).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--production",
|
||||
action="store_true",
|
||||
help="Use Let’s Encrypt production endpoint (default uses staging).",
|
||||
)
|
||||
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("certonly", help="Issue (or re-issue) a certificate via HTTP-01 standalone.")
|
||||
c.add_argument("--email", required=True, help="Email for Let’s Encrypt account registration.")
|
||||
c.add_argument(
|
||||
"--domains",
|
||||
required=True,
|
||||
help="Comma-separated domains (e.g. example.com,www.example.com). The first is used as the primary cert name.",
|
||||
)
|
||||
c.add_argument(
|
||||
"--http-01-port",
|
||||
default=int(os.environ.get("LE_HTTP01_PORT", "80")),
|
||||
type=int,
|
||||
help="Port to bind for HTTP-01 challenge (default: 80).",
|
||||
)
|
||||
c.add_argument(
|
||||
"--challenge",
|
||||
default=os.environ.get("LE_CHALLENGE", "http"),
|
||||
choices=["http", "dns"],
|
||||
help="ACME challenge type: http (standalone) or dns (manual TXT record). Default: http.",
|
||||
)
|
||||
c.add_argument(
|
||||
"--dns-hooks",
|
||||
action="store_true",
|
||||
default=bool(int(os.environ.get("LE_DNS_HOOKS", "0"))),
|
||||
help="(DNS challenge only) Use hook scripts to print token and wait. Default uses certbot's interactive manual prompt.",
|
||||
)
|
||||
c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.")
|
||||
c.add_argument(
|
||||
"--test-cert",
|
||||
action="store_true",
|
||||
help="Obtain a test certificate from staging (alias for staging behavior; kept for familiarity).",
|
||||
)
|
||||
c.set_defaults(_handler=cmd_certonly)
|
||||
|
||||
r = sub.add_parser("renew", help="Renew any existing certificates in the base-dir.")
|
||||
r.add_argument("--dry-run", action="store_true", help="Perform a dry-run renewal.")
|
||||
r.add_argument("--force-renewal", action="store_true", help="Force renewal even if not due.")
|
||||
r.set_defaults(_handler=cmd_renew)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: List[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
||||
handler = getattr(ns, "_handler", None)
|
||||
if handler is None:
|
||||
raise SystemExit("No command selected")
|
||||
return int(handler(ns))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pymongo==4.10.1
|
||||
PyJWT==2.10.1
|
||||
certbot>=2.11.0,<3
|
||||
certifi==2025.11.12
|
||||
@@ -0,0 +1,374 @@
|
||||
import mimetypes
|
||||
import os
|
||||
import posixpath
|
||||
import ssl
|
||||
from functools import lru_cache
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Generator, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import jwt
|
||||
from bson import ObjectId
|
||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import PyMongoError
|
||||
|
||||
|
||||
def _env_str(name: str, default: str = "") -> str:
|
||||
v = os.environ.get(name)
|
||||
return v.strip() if isinstance(v, str) and v.strip() else default
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||
return _env_str("JWT_SECRET")
|
||||
|
||||
|
||||
def _mongo_uri() -> str:
|
||||
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||
return _env_str("MONGO_URI")
|
||||
|
||||
def _mongo_db_name() -> str:
|
||||
# Prefer explicit env var, otherwise try to parse from URI path.
|
||||
explicit = _env_str("MONGO_DB_NAME")
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
uri = _mongo_uri()
|
||||
if not uri:
|
||||
return ""
|
||||
|
||||
try:
|
||||
parsed = urlparse(uri)
|
||||
# Path is like "/dbname"
|
||||
db = (parsed.path or "").lstrip("/")
|
||||
# Strip any extra segments just in case
|
||||
db = db.split("/")[0].strip()
|
||||
return db
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(_env_str(name, str(default)))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _video_files_dir() -> Path:
|
||||
# Read env at runtime (important on Windows/uvicorn worker reloads).
|
||||
return Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
|
||||
|
||||
|
||||
def _parse_bearer(authorization: Optional[str]) -> str:
|
||||
raw = (authorization or "").strip()
|
||||
if raw.lower().startswith("bearer "):
|
||||
return raw[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_safe_relative_file(p: str) -> bool:
|
||||
if not isinstance(p, str):
|
||||
return False
|
||||
s = p.strip()
|
||||
if not s or "\x00" in s:
|
||||
return False
|
||||
if s.startswith("/") or s.startswith("\\"):
|
||||
return False
|
||||
norm = posixpath.normpath(s.replace("\\", "/"))
|
||||
if norm == ".." or norm.startswith("../"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _guess_content_type(file_path: Path) -> str:
|
||||
ctype, _ = mimetypes.guess_type(str(file_path))
|
||||
return ctype or "application/octet-stream"
|
||||
|
||||
|
||||
def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
|
||||
# Only supports a single bytes range: "bytes=start-end"
|
||||
raw = (range_header or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if not raw.lower().startswith("bytes="):
|
||||
return None
|
||||
spec = raw[6:].strip()
|
||||
if "," in spec:
|
||||
return None
|
||||
if "-" not in spec:
|
||||
return None
|
||||
start_s, end_s = spec.split("-", 1)
|
||||
start = int(start_s) if start_s else 0
|
||||
end = int(end_s) if end_s else (file_size - 1)
|
||||
if start < 0 or end < 0 or start > end or start >= file_size:
|
||||
return None
|
||||
end = min(end, file_size - 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
|
||||
with path.open("rb") as f:
|
||||
f.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
n = min(chunk_size, remaining)
|
||||
data = f.read(n)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
|
||||
def _get_token(req: Request, authorization: Optional[str]) -> str:
|
||||
# Prefer cookie (for <video>), then Authorization header, then querystring token.
|
||||
cookie_token = (req.cookies.get("video_auth") or "").strip()
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
header_token = _parse_bearer(authorization)
|
||||
if header_token:
|
||||
return header_token
|
||||
qs = (req.query_params.get("token") or "").strip()
|
||||
return qs
|
||||
|
||||
|
||||
def _verify_token(token: str) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing video auth token")
|
||||
secret = _jwt_secret()
|
||||
if not secret:
|
||||
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
|
||||
try:
|
||||
return jwt.decode(token, secret, algorithms=["HS256"])
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
|
||||
|
||||
|
||||
def _mongo_client() -> MongoClient:
|
||||
uri = _mongo_uri()
|
||||
if not uri:
|
||||
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
|
||||
kwargs = {
|
||||
# Fail fast instead of hanging for ~30s on each request.
|
||||
"serverSelectionTimeoutMS": _env_int("MONGO_SERVER_SELECTION_TIMEOUT_MS", 8000),
|
||||
"connectTimeoutMS": _env_int("MONGO_CONNECT_TIMEOUT_MS", 8000),
|
||||
"socketTimeoutMS": _env_int("MONGO_SOCKET_TIMEOUT_MS", 20000),
|
||||
}
|
||||
|
||||
# On some hosts, the system CA store is missing/broken or overridden by env vars,
|
||||
# causing TLS handshake failures to MongoDB Atlas. Prefer certifi's CA bundle.
|
||||
ca_file = _env_str("MONGO_TLS_CA_FILE")
|
||||
if ca_file:
|
||||
kwargs["tlsCAFile"] = ca_file
|
||||
else:
|
||||
try:
|
||||
import certifi # type: ignore
|
||||
|
||||
kwargs["tlsCAFile"] = certifi.where()
|
||||
except Exception:
|
||||
# If certifi isn't available, fall back to system trust.
|
||||
pass
|
||||
|
||||
# Some hosts / middleboxes break TLS 1.3 handshakes to Atlas and produce
|
||||
# `TLSV1_ALERT_INTERNAL_ERROR`. Forcing TLS 1.2 can fix it.
|
||||
if _env_str("MONGO_TLS_FORCE_TLS12", "0") in ("1", "true", "yes", "on"):
|
||||
cafile = kwargs.pop("tlsCAFile", None)
|
||||
ctx = ssl.create_default_context(cafile=cafile)
|
||||
try:
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
except Exception:
|
||||
# Older Python/OpenSSL may not support TLSVersion fields.
|
||||
pass
|
||||
kwargs["ssl_context"] = ctx
|
||||
|
||||
# Optional escape hatch (NOT recommended): allow invalid certs for debugging only.
|
||||
if _env_str("MONGO_TLS_INSECURE", "0") in ("1", "true", "yes", "on"):
|
||||
kwargs["tlsAllowInvalidCertificates"] = True
|
||||
|
||||
return MongoClient(uri, **kwargs)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _mongo_client_cached() -> MongoClient:
|
||||
# Reuse one client (connection pool) across requests.
|
||||
return _mongo_client()
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# CORS: needed so the app can call the Python host's /api/video/session
|
||||
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
|
||||
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
|
||||
_cors_allow_origin_regex = None
|
||||
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
|
||||
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
|
||||
# Using a regex makes Starlette reflect the requesting Origin instead.
|
||||
_cors_origins = []
|
||||
_cors_allow_origin_regex = ".*"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_origin_regex=_cors_allow_origin_regex,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/video/session")
|
||||
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
|
||||
token = _parse_bearer(authorization)
|
||||
_verify_token(token)
|
||||
|
||||
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
|
||||
encoded = quote(token, safe="")
|
||||
cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "86400")) # 24h (longer default to avoid expiration during long movie sessions)
|
||||
cookie_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
|
||||
samesite = (cookie_samesite or "Lax").strip().lower()
|
||||
if samesite not in ("lax", "strict", "none"):
|
||||
samesite = "lax"
|
||||
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
|
||||
cookie_parts = [
|
||||
f"video_auth={encoded}",
|
||||
"Path=/api/video",
|
||||
f"Max-Age={cookie_max_age_seconds}",
|
||||
f"SameSite={samesite_attr}",
|
||||
"HttpOnly",
|
||||
]
|
||||
# Modern browsers require Secure when SameSite=None
|
||||
if secure or samesite == "none":
|
||||
cookie_parts.append("Secure")
|
||||
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
|
||||
response.status_code = 204
|
||||
return None
|
||||
|
||||
|
||||
@app.api_route("/api/video/{movie_id}", methods=["GET", "HEAD"])
|
||||
def stream_video(
|
||||
movie_id: str,
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
range: Optional[str] = Header(default=None), # noqa: A002
|
||||
):
|
||||
token = _get_token(request, authorization)
|
||||
_verify_token(token)
|
||||
|
||||
try:
|
||||
oid = ObjectId(movie_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid movie id")
|
||||
|
||||
db_name = _mongo_db_name()
|
||||
if not db_name:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.",
|
||||
)
|
||||
|
||||
try:
|
||||
client = _mongo_client_cached()
|
||||
db = client[db_name]
|
||||
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1, "video_file_low": 1})
|
||||
except PyMongoError as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
"Failed to query MongoDB (TLS/connection issue). "
|
||||
"On some hosts you may need a CA bundle; try installing certifi or set MONGO_TLS_CA_FILE. "
|
||||
f"Original error: {type(e).__name__}: {e}"
|
||||
),
|
||||
)
|
||||
|
||||
quality = (request.query_params.get("quality") or "").strip().lower()
|
||||
wants_low = quality == "low"
|
||||
|
||||
video_file = (movie or {}).get("video_file") or ""
|
||||
video_file_low = (movie or {}).get("video_file_low") or ""
|
||||
chosen = video_file_low if wants_low else video_file
|
||||
|
||||
if not isinstance(chosen, str) or not chosen.strip():
|
||||
if wants_low:
|
||||
raise HTTPException(status_code=404, detail="No low-quality server video file configured for this movie")
|
||||
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
|
||||
chosen = chosen.strip()
|
||||
if not _is_safe_relative_file(chosen):
|
||||
raise HTTPException(status_code=400, detail="Invalid video_file path")
|
||||
|
||||
video_dir = _video_files_dir()
|
||||
full_path = (video_dir / chosen).resolve()
|
||||
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
|
||||
raise HTTPException(status_code=400, detail="Invalid video_file path")
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Video file not found on server")
|
||||
|
||||
stat = full_path.stat()
|
||||
file_size = stat.st_size
|
||||
content_type = _guess_content_type(full_path)
|
||||
|
||||
# Shared headers (match the Node server behavior closely)
|
||||
base_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": content_type,
|
||||
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||
}
|
||||
|
||||
if request.method == "HEAD":
|
||||
headers = dict(base_headers)
|
||||
headers["Content-Length"] = str(file_size)
|
||||
return Response(status_code=200, headers=headers)
|
||||
|
||||
r = _parse_range(range or "", file_size)
|
||||
if r is None and (range or ""):
|
||||
# Invalid Range
|
||||
headers = dict(base_headers)
|
||||
headers["Content-Range"] = f"bytes */{file_size}"
|
||||
return Response(status_code=416, headers=headers)
|
||||
|
||||
if r is None:
|
||||
# Full file
|
||||
return FileResponse(
|
||||
path=str(full_path),
|
||||
media_type=content_type,
|
||||
headers=base_headers,
|
||||
)
|
||||
|
||||
start, end = r
|
||||
chunk_size = end - start + 1
|
||||
headers = dict(base_headers)
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
||||
headers["Content-Length"] = str(chunk_size)
|
||||
|
||||
return StreamingResponse(
|
||||
_file_iter(full_path, start, end),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {
|
||||
"ok": True,
|
||||
"time": datetime.now(timezone.utc).isoformat(),
|
||||
"video_files_dir": str(_video_files_dir()),
|
||||
"mongo_db_name": _mongo_db_name() or None,
|
||||
"openssl": ssl.OPENSSL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def _shutdown():
|
||||
# Close the cached Mongo client cleanly.
|
||||
try:
|
||||
client = _mongo_client_cached()
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
const express = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const mongoose = require("mongoose");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const readline = require("readline");
|
||||
const multer = require("multer");
|
||||
const { Transform } = require("stream");
|
||||
|
||||
// Use MongoDB Extended JSON to preserve ObjectId/Date/etc.
|
||||
// bson is a transitive dependency of mongoose (mongodb driver).
|
||||
let EJSON;
|
||||
try {
|
||||
({ EJSON } = require("bson"));
|
||||
} catch (_) {
|
||||
EJSON = null;
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const BACKUP_DIR = path.join(process.cwd(), "backups");
|
||||
const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB
|
||||
// SECURITY: protect against gzip "zip bombs" (small upload -> massive output).
|
||||
// This caps the *decompressed* stream size during restore.
|
||||
const MAX_RESTORE_UNZIPPED_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
function createByteLimitStream(maxBytes) {
|
||||
let seen = 0;
|
||||
return new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
seen += chunk.length;
|
||||
if (seen > maxBytes) {
|
||||
const err = new Error("Restore failed: decompressed data is too large");
|
||||
// mimic our handler's error shape
|
||||
err.statusCode = 413;
|
||||
cb(err);
|
||||
return;
|
||||
}
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requireAdmin(req) {
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
if (!token) {
|
||||
const err = new Error("Authentication token is required");
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
if (!decoded?.admin) {
|
||||
const err = new Error("You do not have admin privileges");
|
||||
err.statusCode = 403;
|
||||
throw err;
|
||||
}
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
if (e instanceof jwt.JsonWebTokenError) {
|
||||
const err = new Error("Invalid or expired token");
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function safeBackupFileName(raw) {
|
||||
const name = String(raw || "").trim();
|
||||
// Only allow simple filenames to avoid traversal.
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return null;
|
||||
if (!name.endsWith(".ndjson.gz")) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
async function ensureBackupDir() {
|
||||
await fs.promises.mkdir(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function nowStamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function backupFileNameFromDb(dbName) {
|
||||
const safeDb = String(dbName || "db").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 60) || "db";
|
||||
return `mongo-backup-${safeDb}-${nowStamp()}.ndjson.gz`;
|
||||
}
|
||||
|
||||
function requireDbReady() {
|
||||
if (!mongoose?.connection || mongoose.connection.readyState !== 1) {
|
||||
const err = new Error("MongoDB connection is not ready");
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
}
|
||||
if (!EJSON) {
|
||||
const err = new Error("bson EJSON is not available in this environment");
|
||||
err.statusCode = 500;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
try {
|
||||
await ensureBackupDir();
|
||||
cb(null, BACKUP_DIR);
|
||||
} catch (e) {
|
||||
cb(e);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Keep uploads isolated; we rename during restore if needed.
|
||||
const name = `upload-${nowStamp()}-${Math.random().toString(16).slice(2)}.ndjson.gz`;
|
||||
cb(null, name);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: MAX_UPLOAD_BYTES },
|
||||
});
|
||||
|
||||
async function listBackupFiles() {
|
||||
await ensureBackupDir();
|
||||
const entries = await fs.promises.readdir(BACKUP_DIR, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => e.name)
|
||||
.filter((n) => safeBackupFileName(n))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
const out = [];
|
||||
for (const name of files) {
|
||||
const full = path.join(BACKUP_DIR, name);
|
||||
try {
|
||||
const stat = await fs.promises.stat(full);
|
||||
out.push({
|
||||
name,
|
||||
sizeBytes: stat.size,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore races
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Admin: list existing backups (metadata only)
|
||||
router.get("/db/backups", async (req, res) => {
|
||||
try {
|
||||
requireAdmin(req);
|
||||
// Admin + auth-scoped data: never allow caching.
|
||||
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.setHeader("Vary", "Authorization");
|
||||
const backups = await listBackupFiles();
|
||||
return res.status(200).json({ backups });
|
||||
} catch (err) {
|
||||
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: create a new backup and store it on the server
|
||||
router.post("/db/backup", async (req, res) => {
|
||||
try {
|
||||
requireAdmin(req);
|
||||
requireDbReady();
|
||||
await ensureBackupDir();
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
const dbName = db?.databaseName || "db";
|
||||
const fileName = backupFileNameFromDb(dbName);
|
||||
const fullPath = path.join(BACKUP_DIR, fileName);
|
||||
|
||||
const gzip = zlib.createGzip({ level: 9 });
|
||||
const fileOut = fs.createWriteStream(fullPath, { flags: "wx" });
|
||||
gzip.pipe(fileOut);
|
||||
|
||||
function writeLine(obj) {
|
||||
const line = EJSON.stringify(obj) + "\n";
|
||||
return gzip.write(line);
|
||||
}
|
||||
|
||||
// Header
|
||||
writeLine({
|
||||
type: "meta",
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
dbName,
|
||||
format: "ndjson+gzip",
|
||||
});
|
||||
|
||||
const collections = await db.listCollections().toArray();
|
||||
const userCollections = collections
|
||||
.map((c) => c?.name)
|
||||
.filter(Boolean)
|
||||
.filter((name) => !String(name).startsWith("system."));
|
||||
|
||||
for (const name of userCollections) {
|
||||
writeLine({ type: "collection", name });
|
||||
const cursor = db.collection(name).find({});
|
||||
// Stream docs one-by-one (no giant in-memory array)
|
||||
for await (const doc of cursor) {
|
||||
writeLine({ type: "doc", collection: name, doc });
|
||||
}
|
||||
}
|
||||
|
||||
writeLine({ type: "end" });
|
||||
gzip.end();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
fileOut.on("finish", resolve);
|
||||
fileOut.on("error", reject);
|
||||
gzip.on("error", reject);
|
||||
});
|
||||
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
return res.status(201).json({
|
||||
message: "Backup created",
|
||||
backup: { name: fileName, sizeBytes: stat.size, createdAt: new Date().toISOString() },
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: download a specific backup
|
||||
router.get("/db/backups/:name", async (req, res) => {
|
||||
try {
|
||||
requireAdmin(req);
|
||||
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.setHeader("Vary", "Authorization");
|
||||
const safe = safeBackupFileName(req.params?.name);
|
||||
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
|
||||
|
||||
const full = path.join(BACKUP_DIR, safe);
|
||||
await fs.promises.access(full, fs.constants.R_OK);
|
||||
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safe}"`);
|
||||
fs.createReadStream(full).pipe(res);
|
||||
} catch (err) {
|
||||
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
|
||||
return res.status(code).json({ message: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: delete a backup file
|
||||
router.delete("/db/backups/:name", async (req, res) => {
|
||||
try {
|
||||
requireAdmin(req);
|
||||
const safe = safeBackupFileName(req.params?.name);
|
||||
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
|
||||
|
||||
const full = path.join(BACKUP_DIR, safe);
|
||||
await fs.promises.unlink(full);
|
||||
return res.status(200).json({ message: "Backup deleted" });
|
||||
} catch (err) {
|
||||
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
|
||||
return res.status(code).json({ message: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: restore from an uploaded backup.
|
||||
// Query: ?drop=true to drop collections before insert (recommended).
|
||||
router.post("/db/restore", upload.single("backup"), async (req, res) => {
|
||||
let uploadedPath = null;
|
||||
try {
|
||||
requireAdmin(req);
|
||||
requireDbReady();
|
||||
|
||||
const drop = String(req.query?.drop || req.body?.drop || "").toLowerCase() === "true";
|
||||
const file = req.file;
|
||||
if (!file?.path) {
|
||||
return res.status(400).json({ message: "No backup file uploaded" });
|
||||
}
|
||||
uploadedPath = file.path;
|
||||
// Best-effort: refuse obviously wrong file names (UI expects .ndjson.gz).
|
||||
const original = String(file.originalname || "").trim().toLowerCase();
|
||||
if (original && !original.endsWith(".gz")) {
|
||||
const err = new Error("Invalid backup file (expected a .gz file)");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
const gunzip = zlib.createGunzip();
|
||||
const byteLimit = createByteLimitStream(MAX_RESTORE_UNZIPPED_BYTES);
|
||||
const input = fs.createReadStream(uploadedPath).pipe(gunzip).pipe(byteLimit);
|
||||
|
||||
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
||||
// Ensure stream errors don't become unhandled.
|
||||
let streamError = null;
|
||||
input.on("error", (e) => {
|
||||
streamError = e;
|
||||
try {
|
||||
rl.close();
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
let meta = null;
|
||||
let currentCollection = null;
|
||||
let batch = [];
|
||||
const dropped = new Set();
|
||||
const insertedCountByCollection = new Map();
|
||||
|
||||
async function flushBatch() {
|
||||
if (!currentCollection || batch.length === 0) return;
|
||||
const coll = db.collection(currentCollection);
|
||||
try {
|
||||
const result = await coll.insertMany(batch, { ordered: false });
|
||||
const inserted = result?.insertedCount || 0;
|
||||
insertedCountByCollection.set(
|
||||
currentCollection,
|
||||
(insertedCountByCollection.get(currentCollection) || 0) + inserted
|
||||
);
|
||||
} catch (e) {
|
||||
// If restoring into an existing DB without dropping, duplicates are likely.
|
||||
// Surface a clear error instead of half-success.
|
||||
const err = new Error(
|
||||
`Restore failed while inserting into "${currentCollection}": ${e?.message || "insertMany error"}`
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
} finally {
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDropped(name) {
|
||||
if (!drop) return;
|
||||
if (dropped.has(name)) return;
|
||||
dropped.add(name);
|
||||
try {
|
||||
await db.collection(name).drop();
|
||||
} catch (e) {
|
||||
// If collection doesn't exist, ignore.
|
||||
if (e?.codeName !== "NamespaceNotFound" && e?.code !== 26) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = String(line || "").trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let obj;
|
||||
try {
|
||||
obj = EJSON.parse(trimmed);
|
||||
} catch (_) {
|
||||
const err = new Error("Invalid backup format (cannot parse JSON line)");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (obj?.type === "meta") {
|
||||
meta = obj;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj?.type === "collection") {
|
||||
await flushBatch();
|
||||
currentCollection = String(obj?.name || "").trim();
|
||||
if (!currentCollection || currentCollection.startsWith("system.")) {
|
||||
currentCollection = null;
|
||||
continue;
|
||||
}
|
||||
await ensureDropped(currentCollection);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj?.type === "doc") {
|
||||
const coll = String(obj?.collection || "").trim();
|
||||
if (!coll || coll.startsWith("system.")) continue;
|
||||
if (currentCollection !== coll) {
|
||||
await flushBatch();
|
||||
currentCollection = coll;
|
||||
await ensureDropped(currentCollection);
|
||||
}
|
||||
if (obj?.doc && typeof obj.doc === "object") {
|
||||
batch.push(obj.doc);
|
||||
if (batch.length >= 500) {
|
||||
await flushBatch();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await flushBatch();
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
// Cleanup upload after successful restore
|
||||
try {
|
||||
await fs.promises.unlink(uploadedPath);
|
||||
uploadedPath = null;
|
||||
} catch (_) {}
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Restore completed",
|
||||
meta,
|
||||
drop,
|
||||
inserted: Object.fromEntries(insertedCountByCollection.entries()),
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort cleanup
|
||||
if (uploadedPath) {
|
||||
try {
|
||||
await fs.promises.unlink(uploadedPath);
|
||||
} catch (_) {}
|
||||
}
|
||||
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1246
-8
File diff suppressed because it is too large
Load Diff
+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;
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
const express = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const Setting = require("../models/Setting");
|
||||
const Movie = require("../models/Movie");
|
||||
const User = require("../models/User");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||
const COMPLETION_MODAL_KEY = "completion_modal_content";
|
||||
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");
|
||||
|
||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||
title: "Félicitations! very nice 🎉🎉🎉",
|
||||
bodyText: "",
|
||||
videoSrc: "video/reward.mp4",
|
||||
bodyHtml: "",
|
||||
});
|
||||
|
||||
const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
|
||||
// Controls the admin-only debug/status block in player.html
|
||||
showSource: true,
|
||||
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);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
if (n < 1900 || n > 3000) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseOptionalPoints(raw) {
|
||||
if (raw === undefined) return null;
|
||||
if (raw === null) return null;
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
// Points can be any finite number, but we store a rounded int for consistency.
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
function parseIsoDateString(raw) {
|
||||
// Expected format: YYYY-MM-DD
|
||||
if (raw === undefined || raw === null) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s) return null;
|
||||
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return null;
|
||||
|
||||
const year = Number(m[1]);
|
||||
const month = Number(m[2]);
|
||||
const day = Number(m[3]);
|
||||
|
||||
if (!Number.isInteger(year) || year < 1900 || year > 3000) return null;
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) return null;
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) return null;
|
||||
|
||||
// Validate calendar date (handles month/day overflow like Feb 30).
|
||||
const d = new Date(Date.UTC(year, month - 1, day));
|
||||
if (
|
||||
d.getUTCFullYear() !== year ||
|
||||
d.getUTCMonth() !== month - 1 ||
|
||||
d.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeOscarDatesByYear(rawValue) {
|
||||
// Stored format: { "2026": "2026-03-15" }
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const out = {};
|
||||
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const year = parseOscarYear(k);
|
||||
if (!year) continue;
|
||||
const date = parseIsoDateString(v);
|
||||
if (!date) continue;
|
||||
out[String(year)] = date;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeWinnersByYear(rawValue) {
|
||||
// Stored format:
|
||||
// - New: { "2026": [ { userId, points? }, { userId, points? } ] }
|
||||
// - Legacy: { "2026": { userId, points? } }
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const out = {};
|
||||
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const year = parseOscarYear(k);
|
||||
if (!year) continue;
|
||||
|
||||
const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : [];
|
||||
const dedup = new Map(); // userId -> {userId, points}
|
||||
|
||||
for (const rawEntry of list) {
|
||||
const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {};
|
||||
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
|
||||
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
|
||||
const points = parseOptionalPoints(entry.points);
|
||||
dedup.set(String(userId), { userId: String(userId), points });
|
||||
}
|
||||
|
||||
const normalizedList = Array.from(dedup.values());
|
||||
if (normalizedList.length) {
|
||||
out[String(year)] = normalizedList;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeHtml(raw) {
|
||||
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
|
||||
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
|
||||
const html = String(raw || "");
|
||||
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, "");
|
||||
const withoutOnAttrs = withoutScripts
|
||||
.replace(/\son\w+="[^"]*"/gi, "")
|
||||
.replace(/\son\w+='[^']*'/gi, "")
|
||||
.replace(/\son\w+=\S+/gi, "");
|
||||
return withoutOnAttrs.trim();
|
||||
}
|
||||
|
||||
function normalizeCompletionModal(rawValue) {
|
||||
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
|
||||
const title = typeof value.title === "string" ? value.title : DEFAULT_COMPLETION_MODAL.title;
|
||||
const bodyText = typeof value.bodyText === "string" ? value.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
|
||||
const videoSrc = typeof value.videoSrc === "string" ? value.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
|
||||
const bodyHtml = typeof value.bodyHtml === "string" ? value.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
|
||||
|
||||
return {
|
||||
title: String(title || "").slice(0, 200).trim() || DEFAULT_COMPLETION_MODAL.title,
|
||||
bodyText: String(bodyText || "").slice(0, 8000),
|
||||
videoSrc: String(videoSrc || "").slice(0, 2048).trim(),
|
||||
bodyHtml: sanitizeHtml(String(bodyHtml || "").slice(0, 20000)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerAdminStatusUi(rawValue) {
|
||||
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showSource : !!v.showSource;
|
||||
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showProgress : !!v.showProgress;
|
||||
return { showSource, showProgress };
|
||||
}
|
||||
|
||||
function createId() {
|
||||
try {
|
||||
// Node 16+ / 18+: randomUUID
|
||||
if (crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||
} catch (_) {}
|
||||
// Fallback: time-based
|
||||
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function normalizeAppVersionControl(rawValue) {
|
||||
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const versions = Array.isArray(v.versions) ? v.versions : [];
|
||||
|
||||
const cleaned = versions
|
||||
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
|
||||
.map((e) => {
|
||||
const id = typeof e.id === "string" ? e.id.trim() : "";
|
||||
const version = typeof e.version === "string" ? e.version.trim() : "";
|
||||
const message = typeof e.message === "string" ? e.message : "";
|
||||
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
|
||||
const d = new Date(dateISO);
|
||||
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
return {
|
||||
id: id || null,
|
||||
version: version ? version.slice(0, 80) : null,
|
||||
message: String(message || "").slice(0, 500),
|
||||
dateISO: safeDateISO,
|
||||
};
|
||||
})
|
||||
.filter((e) => e.id && e.version)
|
||||
.sort((a, b) => {
|
||||
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
|
||||
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
|
||||
return bd - ad;
|
||||
});
|
||||
|
||||
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
|
||||
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
|
||||
|
||||
return {
|
||||
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
|
||||
versions: cleaned,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrInitActiveYear() {
|
||||
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
||||
if (existing && typeof existing.value === "number") return existing.value;
|
||||
|
||||
// Initialize from latest movie year if available, else from current year.
|
||||
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
|
||||
.sort({ year: -1 })
|
||||
.select("year");
|
||||
|
||||
const fallbackYear = latestMovie?.year || new Date().getFullYear();
|
||||
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: ACTIVE_YEAR_KEY },
|
||||
{ $set: { value: year } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return year;
|
||||
}
|
||||
|
||||
// Public: get current active Oscar year used by the site.
|
||||
router.get("/year", async (req, res) => {
|
||||
try {
|
||||
const year = await getOrInitActiveYear();
|
||||
return res.status(200).json({ year });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: get Oscar date for a given year (with fallback).
|
||||
// - If no custom date is configured, fallback is March 15 of that year (legacy behavior).
|
||||
router.get("/oscar-date", async (req, res) => {
|
||||
try {
|
||||
const year = parseOscarYear(req.query?.year);
|
||||
if (!year) {
|
||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
}
|
||||
|
||||
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
|
||||
const dates = normalizeOscarDatesByYear(existing?.value);
|
||||
const configured = dates[String(year)] || null;
|
||||
const effectiveDate = configured || `${String(year)}-03-15`;
|
||||
|
||||
return res.status(200).json({
|
||||
year,
|
||||
date: configured, // configured value (null if unset)
|
||||
effectiveDate, // always present
|
||||
});
|
||||
} catch (err) {
|
||||
// Even if DB is down, keep the legacy fallback.
|
||||
const year = parseOscarYear(req.query?.year);
|
||||
if (year) {
|
||||
return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` });
|
||||
}
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: get all configured Oscar dates.
|
||||
router.get("/oscar-dates", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
|
||||
const dates = normalizeOscarDatesByYear(existing?.value);
|
||||
return res.status(200).json({ dates });
|
||||
} catch (err) {
|
||||
return res.status(200).json({ dates: {} });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set/clear Oscar date for a given year.
|
||||
// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear.
|
||||
router.put("/oscar-date/:year", 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 year = parseOscarYear(req.params?.year);
|
||||
if (!year) {
|
||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
}
|
||||
|
||||
const rawDate = req.body?.date;
|
||||
const date = parseIsoDateString(rawDate);
|
||||
const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === "");
|
||||
|
||||
if (!wantsClear && !date) {
|
||||
return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
|
||||
const dates = normalizeOscarDatesByYear(existing?.value);
|
||||
|
||||
if (wantsClear) {
|
||||
delete dates[String(year)];
|
||||
} else {
|
||||
dates[String(year)] = date;
|
||||
}
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: OSCAR_DATES_BY_YEAR_KEY },
|
||||
{ $set: { value: dates } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({ year, date: dates[String(year)] || null });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set current active Oscar year.
|
||||
router.put("/year", 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 year = parseOscarYear(req.body?.year);
|
||||
if (!year) {
|
||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
}
|
||||
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: ACTIVE_YEAR_KEY },
|
||||
{ $set: { value: year } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({ year: 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 the "100% completion" modal content for checklist.
|
||||
router.get("/completion-modal", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: COMPLETION_MODAL_KEY }).select("value");
|
||||
const normalized = normalizeCompletionModal(existing?.value);
|
||||
return res.status(200).json(normalized);
|
||||
} catch (err) {
|
||||
// If DB is down, still return a sane default so the client can render.
|
||||
return res.status(200).json(DEFAULT_COMPLETION_MODAL);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set the "100% completion" modal content for checklist.
|
||||
router.put("/completion-modal", 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 = normalizeCompletionModal(req.body);
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: COMPLETION_MODAL_KEY },
|
||||
{ $set: { value: normalized } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
return res.status(200).json(normalizeCompletionModal(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 winners by year (optionally with points)
|
||||
router.get("/winners", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||
const normalized = normalizeWinnersByYear(existing?.value);
|
||||
|
||||
const entries = Object.entries(normalized)
|
||||
.flatMap(([yearStr, list]) =>
|
||||
(Array.isArray(list) ? list : []).map((v) => ({
|
||||
year: Number(yearStr),
|
||||
userId: v.userId,
|
||||
points: v.points ?? null,
|
||||
}))
|
||||
)
|
||||
.filter((e) => parseOscarYear(e.year))
|
||||
.sort((a, b) => b.year - a.year);
|
||||
|
||||
const userIds = entries.map((e) => e.userId).filter(Boolean);
|
||||
const users = userIds.length
|
||||
? await User.find({ _id: { $in: userIds } }).select("name").lean()
|
||||
: [];
|
||||
const nameById = new Map(users.map((u) => [String(u._id), u.name]));
|
||||
|
||||
return res.status(200).json({
|
||||
winners: entries.map((e) => ({
|
||||
year: e.year,
|
||||
userId: e.userId,
|
||||
name: nameById.get(String(e.userId)) || null,
|
||||
points: e.points ?? null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: get app version control (active + list)
|
||||
router.get("/app-version", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||
const control = normalizeAppVersionControl(existing?.value);
|
||||
const active = control.activeId
|
||||
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
|
||||
: null;
|
||||
return res.status(200).json({ active, versions: control.versions });
|
||||
} catch (_) {
|
||||
return res.status(200).json({ active: null, versions: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: get player admin status UI flags (source/progress visibility)
|
||||
router.get("/player-admin-status-ui", async (req, res) => {
|
||||
try {
|
||||
const existing = await Setting.findOne({ key: PLAYER_ADMIN_STATUS_UI_KEY }).select("value");
|
||||
const normalized = normalizePlayerAdminStatusUi(existing?.value);
|
||||
return res.status(200).json(normalized);
|
||||
} catch (_) {
|
||||
return res.status(200).json(DEFAULT_PLAYER_ADMIN_STATUS_UI);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: update player admin status UI flags
|
||||
// Body: { showSource?: boolean, showProgress?: boolean }
|
||||
router.put("/player-admin-status-ui", 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 = normalizePlayerAdminStatusUi(req.body);
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: PLAYER_ADMIN_STATUS_UI_KEY },
|
||||
{ $set: { value: normalized } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
return res.status(200).json(normalizePlayerAdminStatusUi(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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: create a new version entry (optionally activates if none or if activate=true)
|
||||
// Body: { version: "1.2.3", message?: "..." }
|
||||
router.post("/app-version", 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 version = typeof req.body?.version === "string" ? req.body.version.trim() : "";
|
||||
const message = typeof req.body?.message === "string" ? req.body.message : "";
|
||||
if (!version) return res.status(400).json({ message: "Version requise (ex: 1.0.0)" });
|
||||
|
||||
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||
const control = normalizeAppVersionControl(existing?.value);
|
||||
|
||||
const entry = {
|
||||
id: createId(),
|
||||
version: version.slice(0, 80),
|
||||
message: String(message || "").slice(0, 500),
|
||||
dateISO: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const next = {
|
||||
activeId: control.activeId,
|
||||
versions: [entry, ...(Array.isArray(control.versions) ? control.versions : [])],
|
||||
};
|
||||
|
||||
const wantsActivate = String(req.query?.activate || "").toLowerCase() === "true";
|
||||
if (!next.activeId || wantsActivate) {
|
||||
next.activeId = entry.id;
|
||||
}
|
||||
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: APP_VERSION_KEY },
|
||||
{ $set: { value: next } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
const normalized = normalizeAppVersionControl(updated?.value);
|
||||
const active = normalized.activeId
|
||||
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||
: null;
|
||||
return res.status(200).json({ active, versions: normalized.versions });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: set active version by id
|
||||
// Body: { id: "<versionId>" }
|
||||
router.put("/app-version/active", 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 id = typeof req.body?.id === "string" ? req.body.id.trim() : "";
|
||||
if (!id) return res.status(400).json({ message: "id requis" });
|
||||
|
||||
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||
const control = normalizeAppVersionControl(existing?.value);
|
||||
const exists = control.versions.find((v) => String(v.id) === String(id));
|
||||
if (!exists) return res.status(404).json({ message: "Version introuvable" });
|
||||
|
||||
const next = { activeId: String(id), versions: control.versions };
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: APP_VERSION_KEY },
|
||||
{ $set: { value: next } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
const normalized = normalizeAppVersionControl(updated?.value);
|
||||
const active = normalized.activeId
|
||||
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||
: null;
|
||||
return res.status(200).json({ active, versions: normalized.versions });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: delete a version entry by id (re-picks active if needed)
|
||||
router.delete("/app-version/:id", 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 id = typeof req.params?.id === "string" ? req.params.id.trim() : "";
|
||||
if (!id) return res.status(400).json({ message: "id requis" });
|
||||
|
||||
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||
const control = normalizeAppVersionControl(existing?.value);
|
||||
const nextVersions = control.versions.filter((v) => String(v.id) !== String(id));
|
||||
const next = {
|
||||
activeId: control.activeId && String(control.activeId) === String(id) ? null : control.activeId,
|
||||
versions: nextVersions,
|
||||
};
|
||||
if (!next.activeId && nextVersions.length) next.activeId = String(nextVersions[0].id);
|
||||
|
||||
const updated = await Setting.findOneAndUpdate(
|
||||
{ key: APP_VERSION_KEY },
|
||||
{ $set: { value: next } },
|
||||
{ upsert: true, new: true }
|
||||
).select("value");
|
||||
|
||||
const normalized = normalizeAppVersionControl(updated?.value);
|
||||
const active = normalized.activeId
|
||||
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
|
||||
: null;
|
||||
return res.status(200).json({ active, versions: normalized.versions });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: add/upsert/clear winners for a given year. Points optional.
|
||||
// - Clear all for year: { userId: "" } (legacy behavior)
|
||||
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
|
||||
// - Replace full list for year: { winners: [{ userId, points? }, ...] }
|
||||
router.put("/winners/:year", 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 year = parseOscarYear(req.params?.year);
|
||||
if (!year) {
|
||||
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
}
|
||||
|
||||
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||
const winners = normalizeWinnersByYear(existing?.value);
|
||||
|
||||
// Replace list mode: { winners: [...] }
|
||||
if (Array.isArray(req.body?.winners)) {
|
||||
const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || [];
|
||||
if (!replacement.length) {
|
||||
delete winners[String(year)];
|
||||
} else {
|
||||
winners[String(year)] = replacement;
|
||||
}
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
return res.status(200).json({ year, winners: winners[String(year)] || [] });
|
||||
}
|
||||
|
||||
// Add/upsert/clear mode (legacy-compatible): { userId, points? }
|
||||
const rawUserId = req.body?.userId;
|
||||
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
|
||||
const points = parseOptionalPoints(req.body?.points);
|
||||
|
||||
// Clear all for year
|
||||
if (!userId) {
|
||||
delete winners[String(year)];
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
return res.status(200).json({ year, winners: [] });
|
||||
}
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(userId)) {
|
||||
return res.status(400).json({ message: "userId invalide" });
|
||||
}
|
||||
|
||||
const winnerUser = await User.findById(userId).select("name").lean();
|
||||
if (!winnerUser) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
const key = String(year);
|
||||
const current = Array.isArray(winners[key]) ? winners[key] : [];
|
||||
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
|
||||
next.push({ userId: String(userId), points });
|
||||
winners[key] = next;
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
year,
|
||||
winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null },
|
||||
winners: winners[String(year)] || [],
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only: remove a specific winner for a given year.
|
||||
router.delete("/winners/:year/:userId", 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 year = parseOscarYear(req.params?.year);
|
||||
if (!year) return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
||||
|
||||
const userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : "";
|
||||
if (!mongoose.Types.ObjectId.isValid(userId)) {
|
||||
return res.status(400).json({ message: "userId invalide" });
|
||||
}
|
||||
|
||||
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
|
||||
const winners = normalizeWinnersByYear(existing?.value);
|
||||
const key = String(year);
|
||||
const current = Array.isArray(winners[key]) ? winners[key] : [];
|
||||
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
|
||||
if (next.length) winners[key] = next;
|
||||
else delete winners[key];
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: WINNERS_BY_YEAR_KEY },
|
||||
{ $set: { value: winners } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return res.status(200).json({ year, winners: winners[key] || [] });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const mongoose = require("mongoose");
|
||||
const Movie = require("../models/Movie");
|
||||
|
||||
const router = express.Router();
|
||||
const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
|
||||
|
||||
function parseCookies(header) {
|
||||
const cookies = {};
|
||||
const raw = String(header || "");
|
||||
if (!raw) return cookies;
|
||||
raw.split(";").forEach((part) => {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx <= 0) return;
|
||||
const k = part.slice(0, idx).trim();
|
||||
const v = part.slice(idx + 1).trim();
|
||||
if (!k) return;
|
||||
cookies[k] = decodeURIComponent(v);
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function getAuthToken(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
|
||||
if (cookieToken) return cookieToken;
|
||||
const authHeader = String(req.headers.authorization || "");
|
||||
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
|
||||
if (headerToken) return headerToken;
|
||||
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
|
||||
return qs || "";
|
||||
}
|
||||
|
||||
function requireAuth(req, res) {
|
||||
const token = getAuthToken(req);
|
||||
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing subtitle auth token" }) };
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return { ok: true, decoded };
|
||||
} catch (_) {
|
||||
return { ok: false, error: res.status(401).json({ message: "Invalid or expired subtitle auth token" }) };
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeRelativeFile(p) {
|
||||
if (typeof p !== "string") return false;
|
||||
const s = p.trim();
|
||||
if (!s) return false;
|
||||
if (s.includes("\0")) return false;
|
||||
if (path.isAbsolute(s)) return false;
|
||||
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
|
||||
if (norm.startsWith("../") || norm === "..") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function streamSubtitle(req, res) {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth.ok) return;
|
||||
|
||||
const { id } = req.params;
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ message: "Invalid movie id" });
|
||||
}
|
||||
|
||||
const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean();
|
||||
if (!movie) return res.status(404).json({ message: "Movie not found" });
|
||||
let subtitleFile = "";
|
||||
const subtitleId = req.params?.subtitleId;
|
||||
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
|
||||
|
||||
if (subtitleId) {
|
||||
if (subtitleId === "legacy") {
|
||||
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
|
||||
} else {
|
||||
const match = subs.find((s) => String(s?._id || "") === String(subtitleId));
|
||||
subtitleFile = typeof match?.file === "string" ? match.file.trim() : "";
|
||||
}
|
||||
} else if (subs.length > 0) {
|
||||
const preferred = subs.find((s) => s?.default) || subs[0];
|
||||
subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : "";
|
||||
} else {
|
||||
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
|
||||
}
|
||||
|
||||
if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" });
|
||||
if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" });
|
||||
|
||||
const fullPath = path.resolve(SUBTITLE_FILES_DIR, subtitleFile);
|
||||
if (!fullPath.startsWith(SUBTITLE_FILES_DIR + path.sep) && fullPath !== SUBTITLE_FILES_DIR) {
|
||||
return res.status(400).json({ message: "Invalid subtitle_file path" });
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(fullPath);
|
||||
} catch (_) {
|
||||
return res.status(404).json({ message: "Subtitle file not found on server" });
|
||||
}
|
||||
if (!stat.isFile()) return res.status(404).json({ message: "Subtitle file not found on server" });
|
||||
|
||||
res.setHeader("Content-Type", "text/vtt; charset=utf-8");
|
||||
res.setHeader("Content-Length", String(stat.size));
|
||||
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
return fs.createReadStream(fullPath).pipe(res);
|
||||
}
|
||||
|
||||
router.get("/:id/:subtitleId", (req, res) => {
|
||||
streamSubtitle(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
router.head("/:id/:subtitleId", (req, res) => {
|
||||
streamSubtitle(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
streamSubtitle(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
router.head("/:id", (req, res) => {
|
||||
streamSubtitle(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+652
-36
@@ -1,19 +1,34 @@
|
||||
const express = require("express");
|
||||
const bcrypt = require("bcryptjs");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
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();
|
||||
|
||||
// Set password for verification
|
||||
|
||||
const woof = process.env.DOG_NAMES ? process.env.DOG_NAMES.split(",") : [];
|
||||
const woof =
|
||||
typeof process.env.DOG_NAMES === "string"
|
||||
? process.env.DOG_NAMES.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const ACTIVE_YEAR_KEY = "active_oscar_year";
|
||||
|
||||
// Fetch movie details from OMDb API
|
||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
|
||||
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
|
||||
if (!apiKey) {
|
||||
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
|
||||
err.code = "OMDB_NOT_CONFIGURED";
|
||||
throw err;
|
||||
}
|
||||
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
||||
|
||||
try {
|
||||
@@ -49,41 +64,290 @@ function verifyToken(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isAdminFromDecoded(decoded) {
|
||||
return !!decoded?.admin;
|
||||
}
|
||||
|
||||
function signUserToken(user) {
|
||||
const isAdmin = user.role === 69;
|
||||
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
|
||||
? process.env.JWT_EXPIRES_IN.trim()
|
||||
: "8h";
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
admin: isAdmin,
|
||||
mustChangePassword: !!user.mustChangePassword,
|
||||
},
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn }
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeThemePreference(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (raw === "light" || raw === "dark") return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateTempPassword() {
|
||||
// 12 chars, URL-safe (no spaces/specials that break copy/paste)
|
||||
return crypto.randomBytes(9).toString("base64url");
|
||||
}
|
||||
|
||||
const TEMP_PASSWORD_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
|
||||
async function getOrInitActiveYear() {
|
||||
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
|
||||
if (existing && typeof existing.value === "number") return existing.value;
|
||||
|
||||
// Initialize from latest movie year if available, else from current year.
|
||||
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
|
||||
.sort({ year: -1 })
|
||||
.select("year");
|
||||
|
||||
const fallbackYear = latestMovie?.year || new Date().getFullYear();
|
||||
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
|
||||
|
||||
await Setting.findOneAndUpdate(
|
||||
{ key: ACTIVE_YEAR_KEY },
|
||||
{ $set: { value: year } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
return year;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FIRST-TIME SETUP ENDPOINTS
|
||||
// These allow creating the first admin user when the database is empty/new
|
||||
// ============================================================================
|
||||
|
||||
// Check if the app needs initial setup (no admin users exist)
|
||||
router.get("/setup/status", async (req, res) => {
|
||||
try {
|
||||
const totalUsers = await User.countDocuments({});
|
||||
const adminCount = await User.countDocuments({ role: 69 });
|
||||
|
||||
res.set("Cache-Control", "no-store");
|
||||
return res.status(200).json({
|
||||
needsSetup: adminCount === 0,
|
||||
hasUsers: totalUsers > 0,
|
||||
hasAdmin: adminCount > 0,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create the first admin user (only works if no admin exists)
|
||||
router.post("/setup/admin", async (req, res) => {
|
||||
try {
|
||||
// Check if an admin already exists
|
||||
const adminCount = await User.countDocuments({ role: 69 });
|
||||
if (adminCount > 0) {
|
||||
return res.status(403).json({
|
||||
message: "Un administrateur existe déjà. Cette fonctionnalité est désactivée.",
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
const nameNorm = typeof name === "string" ? name.trim() : "";
|
||||
const emailNorm = typeof email === "string" ? email.trim().toLowerCase() : "";
|
||||
|
||||
if (!nameNorm) {
|
||||
return res.status(400).json({ message: "Le nom est obligatoire" });
|
||||
}
|
||||
if (!emailNorm) {
|
||||
return res.status(400).json({ message: "L'email est obligatoire" });
|
||||
}
|
||||
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
||||
return res.status(400).json({ message: "Email invalide" });
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({ message: "Le mot de passe doit contenir au moins 6 caractères" });
|
||||
}
|
||||
|
||||
// Check if email is already taken
|
||||
const existing = await User.findOne({ email: emailNorm });
|
||||
if (existing) {
|
||||
return res.status(409).json({ message: "Cet email est déjà utilisé" });
|
||||
}
|
||||
|
||||
// Create the admin user
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const adminUser = new User({
|
||||
name: nameNorm,
|
||||
email: emailNorm,
|
||||
password: hashedPassword,
|
||||
role: 69, // Admin role
|
||||
mustChangePassword: false,
|
||||
});
|
||||
|
||||
await adminUser.save();
|
||||
|
||||
console.log(`[setup] First admin user created: ${emailNorm}`);
|
||||
|
||||
// Return a token so the admin is logged in immediately
|
||||
const token = signUserToken(adminUser);
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Administrateur créé avec succès",
|
||||
token,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user stats with watched movie titles and details from the movies collection
|
||||
router.get("/stats", verifyToken, async (req, res) => {
|
||||
try {
|
||||
// Fetch all users
|
||||
const users = await User.find();
|
||||
// Auth-scoped response: never allow caching.
|
||||
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.set("Vary", "Authorization");
|
||||
|
||||
// Fetch all movies from the database
|
||||
const allMovies = await Movie.find();
|
||||
// Fetch all users (only needed fields)
|
||||
const users = await User.find().select("name watchedMovies").lean();
|
||||
|
||||
// If a year is explicitly provided, use it; otherwise default to the global active year.
|
||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||
|
||||
// Fetch movies (optionally filtered by year)
|
||||
const allMovies = await Movie.find({ year })
|
||||
.select("imdb_id title poster rating category year vod_link player_mode video_src embed_src video_file")
|
||||
.lean();
|
||||
const totalMoviesCount = allMovies.length;
|
||||
|
||||
if (totalMoviesCount === 0) {
|
||||
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) => {
|
||||
// Get watched movies with details from the database
|
||||
const watchedMovies = user.watchedMovies
|
||||
.map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details
|
||||
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
|
||||
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
|
||||
|
||||
const watchedCount = watchedMovies.length;
|
||||
// Join watched movies with movie details for the selected year (keep watchedDate)
|
||||
const watchedRaw = Array.isArray(user?.watchedMovies) ? user.watchedMovies : [];
|
||||
const watchedMoviesDetails = watchedRaw
|
||||
.map((wm) => {
|
||||
const imdbId = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
|
||||
if (!imdbId) return null;
|
||||
const movie = movieByImdbId.get(imdbId);
|
||||
if (!movie) return null;
|
||||
return {
|
||||
movieId: movie?._id ? String(movie._id) : null,
|
||||
imdb_id: movie.imdb_id,
|
||||
title: movie.title || "",
|
||||
poster: movie.poster || "",
|
||||
rating: movie.rating || "",
|
||||
category: movie.category || "",
|
||||
year: Number.isInteger(movie.year) ? movie.year : year,
|
||||
vod_link: movie.vod_link || "",
|
||||
player_mode: movie.player_mode || "auto",
|
||||
video_src: movie.video_src || "",
|
||||
embed_src: movie.embed_src || "",
|
||||
video_file: movie.video_file || "",
|
||||
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate).toISOString() : null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const watchedCount = watchedMoviesDetails.length;
|
||||
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
|
||||
|
||||
const watchedMoviesDetails = watchedMovies.map((movie) => ({
|
||||
imdb_id: movie.imdb_id,
|
||||
title: movie.title
|
||||
}));
|
||||
// Calculate the last watched date (used for ranking tiebreakers - earlier completion = better)
|
||||
let lastWatchedAt = null;
|
||||
watchedMoviesDetails.forEach((wm) => {
|
||||
if (wm.watchedDate) {
|
||||
const d = new Date(wm.watchedDate);
|
||||
if (!lastWatchedAt || d > lastWatchedAt) {
|
||||
lastWatchedAt = d;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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,
|
||||
lastWatchedAt: lastWatchedAt ? lastWatchedAt.toISOString() : null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -94,38 +358,166 @@ router.get("/stats", verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get users who have 100% completion per year (across all years in DB)
|
||||
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
||||
router.get("/completions", verifyToken, async (req, res) => {
|
||||
try {
|
||||
// Auth-scoped response: never allow caching.
|
||||
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.set("Vary", "Authorization");
|
||||
|
||||
const yearsRaw = await Movie.distinct("year");
|
||||
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
||||
.map((y) => Number(y))
|
||||
.filter((y) => parseOscarYear(y))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
if (years.length === 0) {
|
||||
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
|
||||
}
|
||||
|
||||
// Fetch imdb_id + year for all movies that have a valid year
|
||||
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
|
||||
|
||||
const imdbIdsByYear = new Map();
|
||||
const totals = {};
|
||||
|
||||
movies.forEach((m) => {
|
||||
const year = Number(m?.year);
|
||||
if (!parseOscarYear(year)) return;
|
||||
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
|
||||
if (!imdb) return;
|
||||
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
|
||||
imdbIdsByYear.get(year).add(imdb);
|
||||
});
|
||||
|
||||
for (const y of years) {
|
||||
const set = imdbIdsByYear.get(y);
|
||||
totals[String(y)] = set ? set.size : 0;
|
||||
}
|
||||
|
||||
const users = await User.find().select("name watchedMovies.imdb_id watchedMovies.watchedDate").lean();
|
||||
|
||||
const completersByYear = {};
|
||||
for (const y of years) {
|
||||
const total = totals[String(y)] || 0;
|
||||
if (total <= 0) {
|
||||
completersByYear[String(y)] = [];
|
||||
continue;
|
||||
}
|
||||
const yearSet = imdbIdsByYear.get(y) || new Set();
|
||||
|
||||
const completers = [];
|
||||
users.forEach((u) => {
|
||||
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
|
||||
|
||||
// Build a map of imdb_id -> watchedDate for this user's movies in this year
|
||||
const watchedInYear = [];
|
||||
watched.forEach((wm) => {
|
||||
const imdb = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
|
||||
if (!imdb || !yearSet.has(imdb)) return;
|
||||
watchedInYear.push({
|
||||
imdb_id: imdb,
|
||||
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// Count unique movies watched in this year
|
||||
const uniqueImdbIds = new Set(watchedInYear.map((w) => w.imdb_id));
|
||||
const count = uniqueImdbIds.size;
|
||||
|
||||
if (count === total) {
|
||||
// Find the completion date (the latest watchedDate among movies for this year)
|
||||
// This is when the user completed 100% of the checklist
|
||||
let completedAt = null;
|
||||
watchedInYear.forEach((w) => {
|
||||
if (w.watchedDate && (!completedAt || w.watchedDate > completedAt)) {
|
||||
completedAt = w.watchedDate;
|
||||
}
|
||||
});
|
||||
|
||||
completers.push({
|
||||
name: u?.name || "(sans nom)",
|
||||
watchedCount: count,
|
||||
totalMoviesCount: total,
|
||||
completedAt: completedAt ? completedAt.toISOString() : null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by completion date (earliest first = higher rank), then alphabetically for ties
|
||||
completers.sort((a, b) => {
|
||||
// Users with a completion date come before those without
|
||||
if (a.completedAt && !b.completedAt) return -1;
|
||||
if (!a.completedAt && b.completedAt) return 1;
|
||||
// Both have dates: earliest first
|
||||
if (a.completedAt && b.completedAt) {
|
||||
const dateA = new Date(a.completedAt).getTime();
|
||||
const dateB = new Date(b.completedAt).getTime();
|
||||
if (dateA !== dateB) return dateA - dateB;
|
||||
}
|
||||
// Tie-breaker: alphabetical
|
||||
return String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" });
|
||||
});
|
||||
completersByYear[String(y)] = completers;
|
||||
}
|
||||
|
||||
return res.status(200).json({ years, totals, completersByYear });
|
||||
} catch (err) {
|
||||
console.error("Erreur dans l'extraction des completers:", err);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// Register User
|
||||
router.post("/register", async (req, res) => {
|
||||
const { name, email, password, role, verifoof } = req.body;
|
||||
const { name, email, password, verifoof } = req.body;
|
||||
const dogCheckEnabled = woof.length > 0;
|
||||
|
||||
function registerVerification() {
|
||||
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
|
||||
if (!dogCheckEnabled) return true;
|
||||
return woof.includes(verifoof);
|
||||
}
|
||||
|
||||
try {
|
||||
const nameNorm = typeof name === "string" ? name.trim() : "";
|
||||
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||
|
||||
if (!nameNorm) {
|
||||
throw new Error("Le nom est obligatoire");
|
||||
}
|
||||
if (!emailNorm) {
|
||||
throw new Error("L'email est obligatoire");
|
||||
}
|
||||
// Basic sanity check (not a full RFC validator)
|
||||
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
||||
throw new Error("Email invalide.");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
||||
}
|
||||
if (!verifoof) {
|
||||
throw new Error("Le nom du chien doit être défini" );
|
||||
if (dogCheckEnabled && !verifoof) {
|
||||
throw new Error("Le nom du chien doit être défini");
|
||||
}
|
||||
if (!registerVerification()) {
|
||||
return res.status(201).json({ message: "Mauvais nom de chien" });
|
||||
return res.status(400).json({ message: "Mauvais nom de chien" });
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email });
|
||||
const existingUser = await User.findOne({ email: emailNorm });
|
||||
if (existingUser) {
|
||||
return res.status(201).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
||||
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
||||
}
|
||||
|
||||
// Hash password and create user
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = new User({ name, email, password: hashedPassword, role });
|
||||
// SECURITY: never accept role escalation from a public endpoint.
|
||||
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
|
||||
await user.save();
|
||||
|
||||
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
res.status(403).json({ message: "Erreur interne lmao" });
|
||||
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -137,27 +529,251 @@ router.post("/login", async (req, res) => {
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
|
||||
|
||||
// If this account is in "temp password" mode and the temp password has expired, block login.
|
||||
if (user.mustChangePassword && user.tempPasswordExpiresAt && user.tempPasswordExpiresAt instanceof Date) {
|
||||
if (Date.now() > user.tempPasswordExpiresAt.getTime()) {
|
||||
return res.status(401).json({
|
||||
message: "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
|
||||
|
||||
console.log("[debug] " + user.name + " logged in")
|
||||
|
||||
// Check if the user role is 69 and set the admin flag
|
||||
const isAdmin = user.role === 69;
|
||||
|
||||
// Create a token with the user ID, name, and admin flag if the user is an admin
|
||||
const token = jwt.sign(
|
||||
{ id: user._id, name: user.name, admin: isAdmin }, // Include name in the token payload
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "1h" }
|
||||
);
|
||||
|
||||
res.status(200).json({ token });
|
||||
const token = signUserToken(user);
|
||||
res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// User: get theme preference
|
||||
router.get("/theme", verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.set("Vary", "Authorization");
|
||||
|
||||
const user = await User.findById(req.user.id).select("themePreference").lean();
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
return res.status(200).json({ themePreference: user.themePreference || null });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
// User: update theme preference (light/dark or null for system)
|
||||
router.put("/theme", verifyToken, async (req, res) => {
|
||||
try {
|
||||
const preference = normalizeThemePreference(req.body?.themePreference);
|
||||
const provided = req.body?.themePreference;
|
||||
if (provided !== null && provided !== undefined && preference === null) {
|
||||
return res.status(400).json({ message: "Préférence invalide (light/dark/null)." });
|
||||
}
|
||||
|
||||
const update = preference ? { $set: { themePreference: preference } } : { $unset: { themePreference: 1 } };
|
||||
|
||||
const user = await User.findByIdAndUpdate(req.user.id, update, { new: true, select: "themePreference" }).lean();
|
||||
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
return res.status(200).json({ themePreference: user.themePreference || null });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: reset a user's password to a random temp password and force change on next login.
|
||||
router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
|
||||
if (!isAdminFromDecoded(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { email, userId } = req.body || {};
|
||||
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||
const idNorm = typeof userId === "string" ? userId.trim() : "";
|
||||
|
||||
if (!emailNorm && !idNorm) {
|
||||
return res.status(400).json({ message: "Email ou userId requis." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
const tempPassword = generateTempPassword();
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
|
||||
|
||||
user.password = hashedPassword;
|
||||
user.mustChangePassword = true;
|
||||
user.tempPasswordIssuedAt = new Date();
|
||||
user.tempPasswordExpiresAt = expiresAt;
|
||||
await user.save();
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
|
||||
tempPassword,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
user: { id: String(user._id), name: user.name, email: user.email },
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: list users (safe fields only)
|
||||
router.get("/admin/list", verifyToken, async (req, res) => {
|
||||
if (!isAdminFromDecoded(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Admin + auth-scoped data: never allow caching.
|
||||
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||
res.set("Vary", "Authorization");
|
||||
|
||||
const users = await User.find()
|
||||
.select("name email role mustChangePassword")
|
||||
.sort({ name: 1, email: 1 });
|
||||
|
||||
return res.status(200).json(
|
||||
users.map((u) => ({
|
||||
id: String(u._id),
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
admin: u.role === 69,
|
||||
mustChangePassword: !!u.mustChangePassword,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: create a user with a random temp password (forces change on first login).
|
||||
router.post("/admin/create", verifyToken, async (req, res) => {
|
||||
if (!isAdminFromDecoded(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { name, email, admin } = req.body || {};
|
||||
const nameNorm = typeof name === "string" ? name.trim() : "";
|
||||
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||
const makeAdmin = !!admin;
|
||||
|
||||
if (!nameNorm) {
|
||||
return res.status(400).json({ message: "Nom requis." });
|
||||
}
|
||||
if (!emailNorm) {
|
||||
return res.status(400).json({ message: "Email requis." });
|
||||
}
|
||||
// Basic sanity check (not a full RFC validator)
|
||||
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
||||
return res.status(400).json({ message: "Email invalide." });
|
||||
}
|
||||
|
||||
try {
|
||||
const existingUser = await User.findOne({ email: emailNorm });
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
||||
}
|
||||
|
||||
const tempPassword = generateTempPassword();
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
|
||||
|
||||
const user = new User({
|
||||
name: nameNorm,
|
||||
email: emailNorm,
|
||||
password: hashedPassword,
|
||||
role: makeAdmin ? 69 : 0,
|
||||
mustChangePassword: true,
|
||||
tempPasswordIssuedAt: new Date(),
|
||||
tempPasswordExpiresAt: expiresAt,
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Utilisateur créé. Mot de passe temporaire généré.",
|
||||
tempPassword,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: delete a user (and related playback progress)
|
||||
router.post("/admin/delete", verifyToken, async (req, res) => {
|
||||
if (!isAdminFromDecoded(req.user)) {
|
||||
return res.status(403).json({ message: "You do not have admin privileges" });
|
||||
}
|
||||
|
||||
const { email, userId } = req.body || {};
|
||||
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||
const idNorm = typeof userId === "string" ? userId.trim() : "";
|
||||
|
||||
if (!emailNorm && !idNorm) {
|
||||
return res.status(400).json({ message: "Email ou userId requis." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
// Safety: prevent deleting yourself from the admin UI.
|
||||
if (String(user._id) === String(req.user?.id || "")) {
|
||||
return res.status(400).json({ message: "Impossible de supprimer ton propre compte." });
|
||||
}
|
||||
|
||||
// Safety: prevent deleting admin accounts via the UI endpoint.
|
||||
if (user.role === 69) {
|
||||
return res.status(400).json({ message: "Impossible de supprimer un compte admin via cette interface." });
|
||||
}
|
||||
|
||||
await PlaybackProgress.deleteMany({ userId: user._id });
|
||||
await User.deleteOne({ _id: user._id });
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Utilisateur supprimé.",
|
||||
user: { id: String(user._id), name: user.name, email: user.email },
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// User: change password (used after temp-password login). Returns a fresh token.
|
||||
router.post("/change-password", verifyToken, async (req, res) => {
|
||||
const { newPassword } = req.body || {};
|
||||
const pw = typeof newPassword === "string" ? newPassword : "";
|
||||
|
||||
if (!pw || pw.length < 8) {
|
||||
return res.status(400).json({ message: "Le nouveau mot de passe doit contenir au moins 8 caractères." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await User.findById(req.user?.id);
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
const hashedPassword = await bcrypt.hash(pw, 10);
|
||||
user.password = hashedPassword;
|
||||
user.mustChangePassword = false;
|
||||
user.tempPasswordIssuedAt = null;
|
||||
user.tempPasswordExpiresAt = null;
|
||||
await user.save();
|
||||
|
||||
const token = signUserToken(user);
|
||||
return res.status(200).json({ message: "Mot de passe mis à jour.", token });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected route to get movies (example)
|
||||
router.get("/movies", verifyToken, async (req, res) => {
|
||||
// Only authorized users will reach this point
|
||||
|
||||
+96
-43
@@ -1,62 +1,115 @@
|
||||
const axios = require("axios");
|
||||
const express = require("express");
|
||||
const moment = require("moment-timezone");
|
||||
const Setting = require("../models/Setting");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
let latestCommitCache = null;
|
||||
let lastFetchedTime = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
const TIMEZONE = "America/New_York";
|
||||
|
||||
async function fetchLatestCommitFromGitHub() {
|
||||
const githubOwner = process.env.GITHUB_OWNER;
|
||||
const githubRepo = process.env.GITHUB_REPO;
|
||||
// Stored in Mongo via Setting model (admin-controlled).
|
||||
// Shape:
|
||||
// {
|
||||
// activeId: string|null,
|
||||
// versions: [{ id, version, message, dateISO }]
|
||||
// }
|
||||
const APP_VERSION_KEY = "app_version_control";
|
||||
|
||||
function nowFormatted() {
|
||||
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
function getPackageVersion() {
|
||||
try {
|
||||
const url = `https://api.github.com/repos/${githubOwner}/${githubRepo}/commits`;
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
const latestCommit = response.data[0];
|
||||
latestCommitCache = {
|
||||
sha: latestCommit.sha,
|
||||
message: latestCommit.commit.message,
|
||||
author: latestCommit.commit.author.name,
|
||||
date: latestCommit.commit.author.date,
|
||||
};
|
||||
lastFetchedTime = Date.now();
|
||||
} else {
|
||||
throw new Error("No commits found.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching commit:", error.message);
|
||||
// routes/version.js -> ../package.json
|
||||
const pkg = require("../package.json");
|
||||
return typeof pkg?.version === "string" && pkg.version.trim() ? pkg.version.trim() : "dev";
|
||||
} catch (_) {
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch to populate cache
|
||||
fetchLatestCommitFromGitHub();
|
||||
function normalizeAppVersionControl(rawValue) {
|
||||
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
|
||||
const versions = Array.isArray(v.versions) ? v.versions : [];
|
||||
|
||||
// Refresh cache every 5 minutes
|
||||
setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
|
||||
const cleaned = versions
|
||||
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
|
||||
.map((e) => {
|
||||
const id = typeof e.id === "string" ? e.id.trim() : "";
|
||||
const version = typeof e.version === "string" ? e.version.trim() : "";
|
||||
const message = typeof e.message === "string" ? e.message.slice(0, 500) : "";
|
||||
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
|
||||
const d = new Date(dateISO);
|
||||
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
|
||||
return {
|
||||
id: id || null,
|
||||
version: version || null,
|
||||
message: String(message || ""),
|
||||
dateISO: safeDateISO,
|
||||
};
|
||||
})
|
||||
.filter((e) => e.id && e.version)
|
||||
.sort((a, b) => {
|
||||
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
|
||||
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
|
||||
return bd - ad;
|
||||
});
|
||||
|
||||
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
|
||||
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
|
||||
|
||||
// If activeId is invalid, fall back to newest entry.
|
||||
return {
|
||||
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
|
||||
versions: cleaned,
|
||||
};
|
||||
}
|
||||
|
||||
// Route to get the latest commit (cached)
|
||||
router.get("/", async (req, res) => {
|
||||
if (!latestCommitCache) {
|
||||
return res.status(500).json({ message: "Commit data is not available yet." });
|
||||
}
|
||||
// Always return 200 and never rely on GitHub.
|
||||
try {
|
||||
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||
const control = normalizeAppVersionControl(setting?.value);
|
||||
|
||||
res.json({
|
||||
version: latestCommitCache.sha.substring(0, 5),
|
||||
message: latestCommitCache.message,
|
||||
author: latestCommitCache.author,
|
||||
date: moment(latestCommitCache.date)
|
||||
.tz("America/New_York")
|
||||
.format("YYYY-MM-DD HH:mm:ss"),
|
||||
});
|
||||
const active = control.activeId
|
||||
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
|
||||
: null;
|
||||
|
||||
if (!active) {
|
||||
return res.status(200).json({
|
||||
version: getPackageVersion(),
|
||||
message: "Version non configurée (définis-la dans Admin → Version).",
|
||||
author: "",
|
||||
date: nowFormatted(),
|
||||
configured: false,
|
||||
source: "fallback",
|
||||
});
|
||||
}
|
||||
|
||||
const date = active.dateISO
|
||||
? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
|
||||
: nowFormatted();
|
||||
|
||||
return res.status(200).json({
|
||||
version: active.version,
|
||||
message: active.message || "",
|
||||
author: "",
|
||||
date,
|
||||
configured: true,
|
||||
source: "admin",
|
||||
});
|
||||
} catch (_) {
|
||||
// If DB is down, still return something useful.
|
||||
return res.status(200).json({
|
||||
version: getPackageVersion(),
|
||||
message: "Version indisponible (DB).",
|
||||
author: "",
|
||||
date: nowFormatted(),
|
||||
configured: false,
|
||||
source: "fallback",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const mongoose = require("mongoose");
|
||||
const Movie = require("../models/Movie");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function parseCookies(header) {
|
||||
const cookies = {};
|
||||
const raw = String(header || "");
|
||||
if (!raw) return cookies;
|
||||
raw.split(";").forEach((part) => {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx <= 0) return;
|
||||
const k = part.slice(0, idx).trim();
|
||||
const v = part.slice(idx + 1).trim();
|
||||
if (!k) return;
|
||||
cookies[k] = decodeURIComponent(v);
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function isSafeRelativeFile(p) {
|
||||
if (typeof p !== "string") return false;
|
||||
const s = p.trim();
|
||||
if (!s) return false;
|
||||
if (s.includes("\0")) return false;
|
||||
// No absolute paths
|
||||
if (path.isAbsolute(s)) return false;
|
||||
// Normalize and prevent traversal
|
||||
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
|
||||
if (norm.startsWith("../") || norm === "..") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function guessContentType(filePath) {
|
||||
const ext = String(path.extname(filePath) || "").toLowerCase();
|
||||
if (ext === ".mp4") return "video/mp4";
|
||||
if (ext === ".webm") return "video/webm";
|
||||
if (ext === ".ogv" || ext === ".ogg") return "video/ogg";
|
||||
if (ext === ".m4v") return "video/x-m4v";
|
||||
if (ext === ".mov") return "video/quicktime";
|
||||
if (ext === ".m3u8") return "application/vnd.apple.mpegurl";
|
||||
if (ext === ".ts") return "video/mp2t";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function getVideoBaseDir() {
|
||||
// Default to your existing public/video folder.
|
||||
const fromEnv = typeof process.env.VIDEO_FILES_DIR === "string" ? process.env.VIDEO_FILES_DIR.trim() : "";
|
||||
const base = fromEnv || path.join(process.cwd(), "public", "video");
|
||||
return path.resolve(base);
|
||||
}
|
||||
|
||||
function getAuthToken(req) {
|
||||
// <video> can't send Authorization headers, so we use a cookie.
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
|
||||
if (cookieToken) return cookieToken;
|
||||
// Optional fallback for non-browser clients:
|
||||
const authHeader = String(req.headers.authorization || "");
|
||||
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
|
||||
if (headerToken) return headerToken;
|
||||
// Last resort (not recommended, but handy for debugging):
|
||||
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
|
||||
return qs || "";
|
||||
}
|
||||
|
||||
function requireAuth(req, res) {
|
||||
const token = getAuthToken(req);
|
||||
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing video auth token" }) };
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
return { ok: true, decoded };
|
||||
} catch (_) {
|
||||
return { ok: false, error: res.status(401).json({ message: "Invalid or expired video auth token" }) };
|
||||
}
|
||||
}
|
||||
|
||||
// Called by player.html via fetch() (with Authorization header) to set a cookie usable by <video>.
|
||||
router.post("/session", (req, res) => {
|
||||
const authHeader = String(req.headers.authorization || "");
|
||||
const token = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
|
||||
if (!token) return res.status(401).json({ message: "Token is required" });
|
||||
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Cookie scoped to /api/video so it doesn't leak broadly.
|
||||
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
|
||||
const maxAgeSecondsRaw = typeof process.env.VIDEO_SESSION_MAX_AGE_SECONDS === "string" ? process.env.VIDEO_SESSION_MAX_AGE_SECONDS.trim() : "";
|
||||
const maxAgeSeconds = Number.isFinite(Number(maxAgeSecondsRaw)) && Number(maxAgeSecondsRaw) > 0
|
||||
? Math.floor(Number(maxAgeSecondsRaw))
|
||||
: 60 * 60 * 24; // 24h (longer default to avoid expiration during long movie sessions)
|
||||
const cookie = [
|
||||
`video_auth=${encodeURIComponent(token)}`,
|
||||
"Path=/api/video",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
"SameSite=Lax",
|
||||
// If behind HTTPS (recommended), enable Secure.
|
||||
...(req.secure || req.headers["x-forwarded-proto"] === "https" ? ["Secure"] : []),
|
||||
"HttpOnly",
|
||||
].join("; ");
|
||||
|
||||
res.setHeader("Set-Cookie", cookie);
|
||||
return res.status(204).end();
|
||||
} catch (_) {
|
||||
return res.status(401).json({ message: "Invalid or expired token" });
|
||||
}
|
||||
});
|
||||
|
||||
async function streamMovieFile(req, res) {
|
||||
const auth = requireAuth(req, res);
|
||||
if (!auth.ok) return;
|
||||
|
||||
const { id } = req.params;
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({ message: "Invalid movie id" });
|
||||
}
|
||||
|
||||
const qualityRaw = typeof req.query?.quality === "string" ? req.query.quality.trim().toLowerCase() : "";
|
||||
const wantsLow = qualityRaw === "low";
|
||||
|
||||
const movie = await Movie.findById(id).select("video_file video_file_low").lean();
|
||||
const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : "";
|
||||
const videoFileLow = typeof movie?.video_file_low === "string" ? movie.video_file_low.trim() : "";
|
||||
|
||||
const chosen = wantsLow ? videoFileLow : videoFile;
|
||||
if (!chosen) {
|
||||
return res.status(404).json({ message: wantsLow ? "No low-quality server video file configured for this movie" : "No server video file configured for this movie" });
|
||||
}
|
||||
if (!isSafeRelativeFile(chosen)) {
|
||||
return res.status(400).json({ message: "Invalid video_file path" });
|
||||
}
|
||||
|
||||
const baseDir = getVideoBaseDir();
|
||||
const fullPath = path.resolve(baseDir, chosen);
|
||||
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
|
||||
return res.status(400).json({ message: "Invalid video_file path" });
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(fullPath);
|
||||
} catch (_) {
|
||||
return res.status(404).json({ message: "Video file not found on server" });
|
||||
}
|
||||
if (!stat.isFile()) return res.status(404).json({ message: "Video file not found on server" });
|
||||
|
||||
const fileSize = stat.size;
|
||||
const range = String(req.headers.range || "");
|
||||
const contentType = guessContentType(fullPath);
|
||||
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
|
||||
|
||||
// HEAD: only headers (no body)
|
||||
if (req.method === "HEAD") {
|
||||
res.setHeader("Content-Length", String(fileSize));
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
res.setHeader("Content-Length", String(fileSize));
|
||||
return fs.createReadStream(fullPath).pipe(res);
|
||||
}
|
||||
|
||||
// Example: "bytes=0-1023"
|
||||
const match = /^bytes=(\d*)-(\d*)$/i.exec(range);
|
||||
if (!match) {
|
||||
res.setHeader("Content-Range", `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
|
||||
const startRaw = match[1];
|
||||
const endRaw = match[2];
|
||||
let start = startRaw ? Number(startRaw) : 0;
|
||||
let end = endRaw ? Number(endRaw) : fileSize - 1;
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0 || start > end) {
|
||||
res.setHeader("Content-Range", `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
|
||||
if (start >= fileSize) {
|
||||
res.setHeader("Content-Range", `bytes */${fileSize}`);
|
||||
return res.status(416).end();
|
||||
}
|
||||
|
||||
end = Math.min(end, fileSize - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
res.status(206);
|
||||
res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader("Content-Length", String(chunkSize));
|
||||
|
||||
return fs.createReadStream(fullPath, { start, end }).pipe(res);
|
||||
}
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
streamMovieFile(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Video streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
router.head("/:id", (req, res) => {
|
||||
streamMovieFile(req, res).catch((err) => {
|
||||
return res.status(500).json({ error: err?.message || "Video streaming error" });
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user