diff --git a/models/Movie.js b/models/Movie.js index 75f5c67..f00ef0f 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -5,6 +5,7 @@ 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) diff --git a/public/control.html b/public/control.html index cf71f7a..ec226c6 100644 --- a/public/control.html +++ b/public/control.html @@ -645,6 +645,7 @@
+
@@ -1255,11 +1256,16 @@ -
+
-
+
+ + +
Ex: 142 min
+
+
@@ -1270,7 +1276,7 @@
Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.
diff --git a/public/index.html b/public/index.html index a7021bc..fb75fd5 100644 --- a/public/index.html +++ b/public/index.html @@ -47,7 +47,40 @@
-
+ +
+
+ Pool Oscars +
+
+ Première installation
+ Aucun administrateur n'existe. Créez le premier compte admin pour commencer. +
+
+
+ + +
+
+ + +
+
+ + +
Minimum 6 caractères
+
+
+ + +
+ + +
+
+ + +
Pool Oscars
diff --git a/public/js/app_control.js b/public/js/app_control.js index df5271c..b5d1a87 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -82,6 +82,7 @@ window.onload = async function () { const adminMoviesBody = document.getElementById('admin-movies-body'); const refreshMoviesBtn = document.getElementById('refresh-movies'); const deleteSelectedBtn = document.getElementById('delete-selected'); + const updateOmdbSelectedBtn = document.getElementById('update-omdb-selected'); const selectAllBox = document.getElementById('select-all-movies'); const selectedCountEl = document.getElementById('selected-count'); @@ -121,6 +122,7 @@ window.onload = async function () { const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editTitleEl = document.getElementById('edit_title'); const editRatingEl = document.getElementById('edit_rating'); + const editRuntimeEl = document.getElementById('edit_runtime'); const editPosterEl = document.getElementById('edit_poster'); const editDescriptionEl = document.getElementById('edit_description'); const editCamFlagEl = document.getElementById('edit_cam_flag'); @@ -2238,6 +2240,7 @@ window.onload = async function () { const selectedIds = getSelectedMovieIds(); selectedCountEl.textContent = String(selectedIds.length); deleteSelectedBtn.disabled = selectedIds.length === 0; + if (updateOmdbSelectedBtn) updateOmdbSelectedBtn.disabled = selectedIds.length === 0; const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]')); const checked = allBoxes.filter((b) => b.checked).length; @@ -2324,6 +2327,7 @@ window.onload = async function () { editTitleEl.value = movie.title || ''; editRatingEl.value = movie.rating || ''; + if (editRuntimeEl) editRuntimeEl.value = movie.runtime || ''; editPosterEl.value = movie.poster || ''; editDescriptionEl.value = movie.description || ''; if (editCamFlagEl) editCamFlagEl.checked = !!movie.cam; @@ -2359,6 +2363,7 @@ window.onload = async function () { const title = editTitleEl.value; const rating = editRatingEl.value; + const runtime = editRuntimeEl ? editRuntimeEl.value : ''; const poster = editPosterEl.value; const description = editDescriptionEl.value; @@ -2407,6 +2412,7 @@ window.onload = async function () { body.title = title; body.description = description; body.rating = rating; + body.runtime = runtime; body.poster = poster; } @@ -2619,6 +2625,65 @@ window.onload = async function () { } }); + if (updateOmdbSelectedBtn) { + updateOmdbSelectedBtn.addEventListener('click', async () => { + const selectedIds = getSelectedMovieIds(); + if (!selectedIds.length) return; + + const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : ''; + const ok = window.confirm(`Rafraîchir ${selectedIds.length} film(s)${yearLabel} depuis OMDb ? Cette opération peut écraser les infos (titre, plot, rating, poster, runtime).`); + if (!ok) return; + + updateOmdbSelectedBtn.disabled = true; + updateOmdbSelectedBtn.textContent = 'Refresh OMDb...'; + + let successCount = 0; + let errorCount = 0; + const errors = []; + + for (const movieId of selectedIds) { + try { + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ refreshOmdb: true }) + }); + + if (!res.ok) { + let msg = `Erreur (${res.status})`; + try { + const data = await res.json(); + msg = data.message || data.error || msg; + } catch (_) {} + errors.push(`${movieId}: ${msg}`); + errorCount++; + } else { + successCount++; + } + } catch (err) { + errors.push(`${movieId}: ${err.message || 'Erreur réseau'}`); + errorCount++; + } + } + + updateOmdbSelectedBtn.disabled = false; + updateOmdbSelectedBtn.textContent = 'Refresh OMDb'; + + if (errorCount === 0) { + showResponse('success', `${successCount} film(s) mis à jour depuis OMDb.`); + } else if (successCount === 0) { + showResponse('danger', `Échec pour ${errorCount} film(s). ${errors.slice(0, 3).join('; ')}`); + } else { + showResponse('warning', `${successCount} succès, ${errorCount} échec(s). ${errors.slice(0, 2).join('; ')}`); + } + + await loadMoviesForManagement(); + }); + } + if (saveMovieChangesBtn) { saveMovieChangesBtn.addEventListener('click', saveMovieChanges); } diff --git a/public/js/app_index.js b/public/js/app_index.js index e3efd56..f42b0a4 100644 --- a/public/js/app_index.js +++ b/public/js/app_index.js @@ -22,49 +22,132 @@ async function applyActiveOscarYearToTitle() { // Fire and forget. applyActiveOscarYearToTitle(); -// Check if the user is already logged in by looking for auth_token in localStorage -const existingToken = localStorage.getItem('auth_token'); -if (existingToken) { +// Check setup status first +async function checkSetupStatus() { 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'; - } else { - window.location.href = '/movies.html'; - } + const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' }); + if (!res.ok) return { needsSetup: false }; + return await res.json(); } catch (_) { - localStorage.removeItem('auth_token'); + return { needsSetup: false }; } } - // 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 }) - }); +async function initPage() { + const setupCard = document.getElementById('setup-card'); + const loginCard = document.getElementById('login-card'); - if (res.ok) { - const data = await res.json(); - // Save the token to localStorage - localStorage.setItem('auth_token', data.token); + // Check if setup is needed + const status = await checkSetupStatus(); - if (data.mustChangePassword) { + if (status.needsSetup) { + // Show setup card, hide login card + if (setupCard) setupCard.classList.remove('d-none'); + if (loginCard) loginCard.classList.add('d-none'); + return; + } + + // 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'; // Redirect to movies page on success + window.location.href = '/movies.html'; + return; } - } else { - const error = await res.json(); - alert(`${error.message}`); + } catch (_) { + localStorage.removeItem('auth_token'); } - }); \ No newline at end of file + } +} + +// 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 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; + + // 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 }) + }); + + const data = await res.json(); + + 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}`); + } +}); \ No newline at end of file diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 16b15d9..11d81c2 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -160,6 +160,20 @@ window.addEventListener('DOMContentLoaded', async function () { 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))}`, { @@ -571,6 +585,7 @@ window.addEventListener('DOMContentLoaded', async function () { : `${movie.title}` } + ${movie.runtime && movie.runtime !== 'N/A' ? `
${formatRuntime(movie.runtime)}
` : ''}
${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}
diff --git a/public/js/app_register.js b/public/js/app_register.js index 91fa923..93845ff 100644 --- a/public/js/app_register.js +++ b/public/js/app_register.js @@ -22,24 +22,55 @@ async function applyActiveOscarYearToTitle() { // Fire and forget. applyActiveOscarYearToTitle(); -// Check if the user is already logged in by looking for auth_token in localStorage -const existingToken = localStorage.getItem('auth_token'); -if (existingToken) { +// Check setup status +async function checkSetupStatus() { 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'; - } else { - window.location.href = '/movies.html'; - } + const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' }); + if (!res.ok) return { needsSetup: false }; + return await res.json(); } catch (_) { - localStorage.removeItem('auth_token'); + 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) { e.preventDefault(); diff --git a/public/register.html b/public/register.html index 1504693..5db047d 100644 --- a/public/register.html +++ b/public/register.html @@ -40,7 +40,20 @@
-
+ +
+
+ Pool Oscars +
+
+ Configuration requise
+ L'application doit d'abord être configurée par un administrateur. +
+ Aller à la page de configuration +
+ + +
Pool Oscars
diff --git a/routes/movies.js b/routes/movies.js index be918a9..a3ba38a 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -242,6 +242,7 @@ async function fetchMovieDetailsFromOmdb(imdb_id) { title: response.data.Title, description: response.data.Plot, rating: response.data.imdbRating, + runtime: response.data.Runtime, poster: response.data.Poster }; } else { @@ -289,7 +290,7 @@ router.get("/", async (req, res) => { const projection = isChecklist ? "imdb_id title" - : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt"; + : "imdb_id title description rating runtime poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt"; const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`; const cached = cacheGet(cacheKey); @@ -583,6 +584,7 @@ router.post("/add", async (req, res) => { title: String(imdb_id || "").trim() || "Untitled", description: "", rating: "", + runtime: "", poster: "" }; } else { @@ -596,6 +598,7 @@ router.post("/add", async (req, res) => { title: movieDetails.title, description: movieDetails.description, rating: movieDetails.rating, + runtime: movieDetails.runtime, poster: movieDetails.poster, cam: !!cam, year: parsedYear, @@ -852,6 +855,7 @@ router.put("/:id", async (req, res) => { title, description, rating, + runtime, poster, year, category, @@ -1008,6 +1012,7 @@ router.put("/:id", async (req, res) => { movie.title = details.title; movie.description = details.description; movie.rating = details.rating; + movie.runtime = details.runtime; movie.poster = details.poster; } catch (err) { if (err?.code === "OMDB_NOT_CONFIGURED") { @@ -1019,6 +1024,7 @@ router.put("/:id", async (req, res) => { if (title !== undefined) movie.title = title; if (description !== undefined) movie.description = description; if (rating !== undefined) movie.rating = rating; + if (runtime !== undefined) movie.runtime = runtime; if (poster !== undefined) movie.poster = poster; } diff --git a/routes/users.js b/routes/users.js index 3e8d9db..e75c5d2 100644 --- a/routes/users.js +++ b/routes/users.js @@ -128,6 +128,90 @@ async function getOrInitActiveYear() { 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 {