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/checklist.html b/public/checklist.html index 42faa1c..0636842 100644 --- a/public/checklist.html +++ b/public/checklist.html @@ -73,7 +73,7 @@ diff --git a/public/control.html b/public/control.html index cf71f7a..9a16b14 100644 --- a/public/control.html +++ b/public/control.html @@ -223,7 +223,7 @@ @@ -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/css/app.css b/public/css/app.css index 0b47619..146fe00 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -905,6 +905,22 @@ footer { .btn-warning:focus, .btn-warning:focus-visible { box-shadow: 0 0 0 0.2rem rgba(29, 78, 216, 0.35); + outline: none; + } + + .btn-warning:active, + .btn-warning.active, + .btn-warning:active:focus { + background-color: #1e40af; + border-color: #1e40af; + color: #fff; + box-shadow: none; + outline: none; + } + + .btn-warning:focus:not(:focus-visible) { + box-shadow: none; + outline: none; } html[data-theme="dark"] .badge.bg-light { @@ -1274,8 +1290,8 @@ footer { /* Dark mode: form-switch toggle visibility */ html[data-theme="dark"] .form-switch .form-check-input { - background-color: #495057; - border-color: #6c757d; + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.7%29'/%3e%3c/svg%3e"); } @@ -1285,6 +1301,12 @@ footer { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); } + /* Ensure form-check/form-switch has no background (inherits from parent container) */ + .form-check, + .form-switch { + background: transparent; + } + .theme-toggle { position: relative; display: inline-flex; 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/movies.html b/public/movies.html index 7775dc4..ccf9e77 100644 --- a/public/movies.html +++ b/public/movies.html @@ -60,7 +60,7 @@ diff --git a/public/picks.html b/public/picks.html index 508d30f..6923603 100644 --- a/public/picks.html +++ b/public/picks.html @@ -133,7 +133,7 @@ diff --git a/public/player.html b/public/player.html index ec8658e..377ce24 100644 --- a/public/player.html +++ b/public/player.html @@ -214,7 +214,7 @@ 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/public/user-stat.html b/public/user-stat.html index 74d9628..ee916d6 100644 --- a/public/user-stat.html +++ b/public/user-stat.html @@ -116,7 +116,7 @@ diff --git a/public/view-picks.html b/public/view-picks.html index 0abf0a1..9f3d452 100644 --- a/public/view-picks.html +++ b/public/view-picks.html @@ -60,7 +60,7 @@