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:
co-authored by
Claude Opus 4.5
parent
087f4f4023
commit
6186f3a051
@@ -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
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user