From eeb337fe086a3eb3d454b427f941763a42b6f30d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Dec 2025 22:14:47 +0000 Subject: [PATCH] feat: Add Oscar year to movies and admin management Co-authored-by: mathieumoran1 --- models/Movie.js | 2 + public/control.html | 53 +++++++++++++ public/js/app_control.js | 166 ++++++++++++++++++++++++++++++++++++++- routes/movies.js | 80 ++++++++++++++++++- routes/users.js | 21 ++++- 5 files changed, 315 insertions(+), 7 deletions(-) diff --git a/models/Movie.js b/models/Movie.js index 9a81bdf..8e8d3ef 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -6,6 +6,8 @@ const movieSchema = new mongoose.Schema({ description: { type: String, required: false }, rating: { type: String, required: false }, poster: { type: String, required: 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 }, watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] diff --git a/public/control.html b/public/control.html index 954c45e..4bafe42 100644 --- a/public/control.html +++ b/public/control.html @@ -60,6 +60,11 @@
+
+ + +
Permet de séparer les films et les stats par année.
+
@@ -77,6 +82,54 @@
+ +
+ +

Admin - Gérer les films

+

+ Sélectionne une année, puis coche les films à supprimer. +

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ 0 sélectionné(s) +
+
+ +
+ + + + + + + + + + + + +
Sel.TitreCatégorieAnnée
Charge la liste…
+
+
diff --git a/public/js/app_control.js b/public/js/app_control.js index 3e48a86..7df46a2 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -31,6 +31,14 @@ window.onload = async function () { const responseEl = document.getElementById('response'); const form = document.getElementById('add-movie-form'); + const yearInput = document.getElementById('oscar_year'); + + const manageYearSelect = document.getElementById('manage_year'); + const adminMoviesBody = document.getElementById('admin-movies-body'); + const refreshMoviesBtn = document.getElementById('refresh-movies'); + const deleteSelectedBtn = document.getElementById('delete-selected'); + const selectAllBox = document.getElementById('select-all-movies'); + const selectedCountEl = document.getElementById('selected-count'); function showResponse(kind, message) { responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); @@ -38,6 +46,13 @@ window.onload = async function () { responseEl.textContent = message; } + function parseYear(value) { + const n = Number(value); + if (!Number.isInteger(n)) return null; + if (n < 1900 || n > 3000) return null; + return n; + } + document.getElementById('reset-form').addEventListener('click', function () { form.reset(); responseEl.classList.add('d-none'); @@ -51,10 +66,16 @@ window.onload = async function () { form.addEventListener('submit', async function (e) { e.preventDefault(); + const year = parseYear(yearInput.value); const imdb_id = document.getElementById('imdb_id').value.trim(); const category = document.getElementById('category').value.trim(); const vod_link = document.getElementById('vod_link').value.trim(); + if (!year) { + showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + return; + } + if (!/^tt\d{5,}$/.test(imdb_id)) { showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567'); return; @@ -67,12 +88,14 @@ window.onload = async function () { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ imdb_id, category, vod_link }) + body: JSON.stringify({ year, imdb_id, category, vod_link }) }); if (res.ok) { showResponse('success', 'Film ajouté avec succès.'); form.reset(); + await refreshYears(); + await loadMoviesForManagement(); return; } @@ -88,5 +111,146 @@ window.onload = async function () { showResponse('danger', err.message || 'Erreur réseau'); } }); + + async function refreshYears() { + try { + const yearsRes = await fetch('/api/movies/years'); + const years = yearsRes.ok ? await yearsRes.json() : []; + + // Populate both the add form (default) and manage dropdown + const existingValues = new Set([...manageYearSelect.options].map((o) => o.value)); + years.forEach((y) => { + const value = String(y); + if (!existingValues.has(value)) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = value; + manageYearSelect.appendChild(opt); + } + }); + + // Default add-year to latest year if empty + if (years.length > 0 && !yearInput.value) { + yearInput.value = String(years[0]); + } + } catch (_) { + // ignore + } + } + + function getSelectedMovieIds() { + return Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]:checked')) + .map((el) => el.getAttribute('data-movie-id')) + .filter(Boolean); + } + + function updateSelectionUI() { + const selectedIds = getSelectedMovieIds(); + selectedCountEl.textContent = String(selectedIds.length); + deleteSelectedBtn.disabled = selectedIds.length === 0; + + const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]')); + const checked = allBoxes.filter((b) => b.checked).length; + selectAllBox.indeterminate = checked > 0 && checked < allBoxes.length; + selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length; + } + + async function loadMoviesForManagement() { + const year = manageYearSelect.value; + const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies'; + adminMoviesBody.innerHTML = `Chargement…`; + + try { + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) { + adminMoviesBody.innerHTML = `Erreur lors du chargement (${res.status})`; + return; + } + const movies = await res.json(); + movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' })); + + if (!movies.length) { + adminMoviesBody.innerHTML = `Aucun film.`; + updateSelectionUI(); + return; + } + + adminMoviesBody.innerHTML = ''; + movies.forEach((m) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + + + + +
${m.title || '(sans titre)'}
+
${m.imdb_id || ''}
+ + ${m.category || ''} + ${m.year || ''} + `; + adminMoviesBody.appendChild(tr); + }); + + adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => { + cb.addEventListener('change', updateSelectionUI); + }); + + updateSelectionUI(); + } catch (err) { + adminMoviesBody.innerHTML = `${err.message || 'Erreur réseau'}`; + } + } + + refreshMoviesBtn.addEventListener('click', loadMoviesForManagement); + manageYearSelect.addEventListener('change', loadMoviesForManagement); + + selectAllBox.addEventListener('change', () => { + const checked = selectAllBox.checked; + adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => { + cb.checked = checked; + }); + updateSelectionUI(); + }); + + deleteSelectedBtn.addEventListener('click', async () => { + const selectedIds = getSelectedMovieIds(); + if (!selectedIds.length) return; + + const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : ''; + const ok = window.confirm(`Supprimer ${selectedIds.length} film(s)${yearLabel} ? Cette action est irréversible.`); + if (!ok) return; + + try { + const res = await fetch('/api/movies/delete', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ ids: selectedIds }) + }); + + if (!res.ok) { + let msg = `Erreur (${res.status})`; + try { + const data = await res.json(); + msg = data.message || data.error || msg; + } catch (_) {} + showResponse('danger', msg); + return; + } + + showResponse('success', 'Film(s) supprimé(s) avec succès.'); + await refreshYears(); + await loadMoviesForManagement(); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } + }); + + // Initial load + await refreshYears(); + await loadMoviesForManagement(); }; diff --git a/routes/movies.js b/routes/movies.js index 1794197..6642d5b 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -6,6 +6,14 @@ const jwt = require("jsonwebtoken"); const router = express.Router(); +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; +} + // Fetch movie details from OMDb API async function fetchMovieDetailsFromOmdb(imdb_id) { const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key @@ -28,10 +36,25 @@ async function fetchMovieDetailsFromOmdb(imdb_id) { } } +// Get available Oscar years (distinct years from movies) +router.get("/years", async (req, res) => { + try { + const years = await Movie.distinct("year"); + const cleaned = years + .filter((y) => typeof y === "number" && Number.isFinite(y)) + .sort((a, b) => b - a); + res.status(200).json(cleaned); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // Get all movies router.get("/", async (req, res) => { try { - const movies = await Movie.find(); + const year = parseOscarYear(req.query.year); + const filter = year ? { year } : {}; + const movies = await Movie.find(filter); res.status(200).json(movies); } catch (err) { res.status(500).json({ error: err.message }); @@ -114,7 +137,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => { // Add movie router.post("/add", async (req, res) => { - const { imdb_id, category, vod_link } = req.body; + const { imdb_id, category, vod_link, year } = req.body; try { // Get the token from the Authorization header @@ -132,6 +155,11 @@ router.post("/add", async (req, res) => { return res.status(403).json({ message: "You do not have admin privileges" }); } + const parsedYear = parseOscarYear(year); + if (!parsedYear) { + return res.status(400).json({ message: "Année invalide (ex: 2026)" }); + } + // Fetch movie details from OMDb API const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id); @@ -142,6 +170,7 @@ router.post("/add", async (req, res) => { description: movieDetails.description, rating: movieDetails.rating, poster: movieDetails.poster, + year: parsedYear, category, vod_link }); @@ -157,4 +186,51 @@ router.post("/add", async (req, res) => { } }); +// Admin: delete one or more movies (by Mongo _id or imdb_id) +router.delete("/delete", 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 { ids, imdb_ids } = req.body || {}; + const idList = Array.isArray(ids) ? ids.filter(Boolean) : []; + const imdbList = Array.isArray(imdb_ids) ? imdb_ids.filter(Boolean) : []; + + if (idList.length === 0 && imdbList.length === 0) { + return res.status(400).json({ message: "Aucun film sélectionné." }); + } + + const filter = idList.length + ? { _id: { $in: idList } } + : { imdb_id: { $in: imdbList } }; + + const moviesToDelete = await Movie.find(filter).select("imdb_id"); + const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean); + + const result = await Movie.deleteMany(filter); + + if (deletedImdbIds.length > 0) { + await User.updateMany( + {}, + { $pull: { watchedMovies: { imdb_id: { $in: deletedImdbIds } } } } + ); + } + + return res.status(200).json({ + message: "Movies deleted successfully", + deletedCount: result.deletedCount || 0, + }); + } 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; diff --git a/routes/users.js b/routes/users.js index 0a1f5ee..d87cda1 100644 --- a/routes/users.js +++ b/routes/users.js @@ -49,14 +49,24 @@ 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; +} + // 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(); - // Fetch all movies from the database - const allMovies = await Movie.find(); + const year = parseOscarYear(req.query.year); + + // Fetch movies (optionally filtered by year) + const allMovies = await Movie.find(year ? { year } : {}); const totalMoviesCount = allMovies.length; if (totalMoviesCount === 0) { @@ -65,10 +75,12 @@ router.get("/stats", verifyToken, async (req, res) => { // Build stats for each user const userStats = users.map((user) => { + const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m])); + // 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) + .map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details + .filter(Boolean); // Remove any `undefined` values (if movie is not found) const watchedCount = watchedMovies.length; const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1); @@ -80,6 +92,7 @@ router.get("/stats", verifyToken, async (req, res) => { return { name: user.name, + year: year || null, watchedCount, totalMoviesCount, watchedRatio: `${watchedRatio}%`,