Add movie runtime, bulk OMDb refresh, and first-time setup

- Add runtime field to Movie model and display formatted duration (e.g., 2h 22min) on movie cards
- Add bulk "Refresh OMDb" button in admin panel for selected movies
- Add runtime field to movie edit modal in admin panel
- Implement first-time setup flow: show admin creation form on login page when no admin exists
- Block registration page until initial setup is complete

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Mathieu Moran
2026-02-01 10:08:15 -05:00
co-authored by Claude Opus 4.5
parent 087f4f4023
commit 6186f3a051
10 changed files with 391 additions and 54 deletions
+65
View File
@@ -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);
}
+118 -35
View File
@@ -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');
}
});
}
}
// 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}`);
}
});
+15
View File
@@ -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 () {
: `<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>
+44 -13
View File
@@ -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();