feat: Add movie editing functionality and modal

This commit introduces the ability to edit movie details through a modal interface. It includes backend API endpoints for updating movie information and frontend logic to display and manage the edit modal. The changes also refine the year selection and movie loading processes in the admin panel.

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-20 22:24:38 +00:00
co-authored by mathieumoran1
parent 9ac9b1bd04
commit e0ec4760ce
3 changed files with 327 additions and 14 deletions
+70 -1
View File
@@ -122,16 +122,85 @@
<th>Titre</th> <th>Titre</th>
<th style="width: 20%;">Catégorie</th> <th style="width: 20%;">Catégorie</th>
<th style="width: 12%;">Année</th> <th style="width: 12%;">Année</th>
<th style="width: 1%;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="admin-movies-body"> <tbody id="admin-movies-body">
<tr><td colspan="4" class="text-muted">Charge la liste…</td></tr> <tr><td colspan="5" class="text-muted">Charge la liste…</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- Edit movie modal -->
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editMovieModalTitle">Modifier le film</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit_movie_id">
<div class="row g-3">
<div class="col-md-6">
<label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
<input type="text" class="form-control" id="edit_imdb_id" placeholder="tt..." required>
</div>
<div class="col-md-6">
<label for="edit_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="edit_year" placeholder="2026" min="1900" max="3000">
</div>
<div class="col-md-6">
<label for="edit_category" class="form-label">Catégorie</label>
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
</div>
<div class="col-md-6">
<label for="edit_vod_link" class="form-label">Lien VOD</label>
<input type="url" class="form-control" id="edit_vod_link" placeholder="https://..." required>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_refresh_omdb">
<label class="form-check-label" for="edit_refresh_omdb">
Recharger depuis OMDb (écrase titre / plot / rating / poster)
</label>
</div>
<div class="form-text">Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.</div>
</div>
<div class="col-md-6">
<label for="edit_title" class="form-label">Titre</label>
<input type="text" class="form-control" id="edit_title" placeholder="Title">
</div>
<div class="col-md-6">
<label for="edit_rating" class="form-label">Rating (imdbRating)</label>
<input type="text" class="form-control" id="edit_rating" placeholder="7.9">
</div>
<div class="col-12">
<label for="edit_poster" class="form-label">Poster URL</label>
<input type="url" class="form-control" id="edit_poster" placeholder="https://...">
</div>
<div class="col-12">
<label for="edit_description" class="form-label">Description (Plot)</label>
<textarea class="form-control" id="edit_description" rows="4" placeholder="Plot..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-primary" id="save-movie-changes">Enregistrer</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_control.js"></script> <script src="./js/app_control.js"></script>
+170 -11
View File
@@ -40,6 +40,24 @@ window.onload = async function () {
const selectAllBox = document.getElementById('select-all-movies'); const selectAllBox = document.getElementById('select-all-movies');
const selectedCountEl = document.getElementById('selected-count'); const selectedCountEl = document.getElementById('selected-count');
const editMovieModalEl = document.getElementById('editMovieModal');
const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
const saveMovieChangesBtn = document.getElementById('save-movie-changes');
const editMovieIdEl = document.getElementById('edit_movie_id');
const editImdbIdEl = document.getElementById('edit_imdb_id');
const editYearEl = document.getElementById('edit_year');
const editCategoryEl = document.getElementById('edit_category');
const editVodLinkEl = document.getElementById('edit_vod_link');
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title');
const editRatingEl = document.getElementById('edit_rating');
const editPosterEl = document.getElementById('edit_poster');
const editDescriptionEl = document.getElementById('edit_description');
let moviesById = new Map();
function showResponse(kind, message) { function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger'); responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
@@ -53,6 +71,10 @@ window.onload = async function () {
return n; return n;
} }
function isValidImdbId(value) {
return /^tt\d{5,}$/.test(String(value || '').trim());
}
document.getElementById('reset-form').addEventListener('click', function () { document.getElementById('reset-form').addEventListener('click', function () {
form.reset(); form.reset();
responseEl.classList.add('d-none'); responseEl.classList.add('d-none');
@@ -117,18 +139,24 @@ window.onload = async function () {
const yearsRes = await fetch('/api/movies/years'); const yearsRes = await fetch('/api/movies/years');
const years = yearsRes.ok ? await yearsRes.json() : []; const years = yearsRes.ok ? await yearsRes.json() : [];
// Populate both the add form (default) and manage dropdown // Rebuild manage dropdown from scratch (so removed years disappear)
const existingValues = new Set([...manageYearSelect.options].map((o) => o.value)); const previousSelection = manageYearSelect.value;
manageYearSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = 'Toutes';
manageYearSelect.appendChild(allOpt);
years.forEach((y) => { years.forEach((y) => {
const value = String(y);
if (!existingValues.has(value)) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = String(y);
opt.textContent = value; opt.textContent = String(y);
manageYearSelect.appendChild(opt); manageYearSelect.appendChild(opt);
}
}); });
const stillExists = years.map(String).includes(previousSelection);
manageYearSelect.value = stillExists ? previousSelection : '';
// Default add-year to latest year if empty // Default add-year to latest year if empty
if (years.length > 0 && !yearInput.value) { if (years.length > 0 && !yearInput.value) {
yearInput.value = String(years[0]); yearInput.value = String(years[0]);
@@ -155,22 +183,140 @@ window.onload = async function () {
selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length; selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length;
} }
function openEditModal(movieId) {
if (!editMovieModal) return;
const movie = moviesById.get(movieId);
if (!movie) return;
editMovieIdEl.value = movie._id || '';
editImdbIdEl.value = movie.imdb_id || '';
editYearEl.value = movie.year ? String(movie.year) : '';
editCategoryEl.value = movie.category || '';
editVodLinkEl.value = movie.vod_link || '';
editRefreshOmdbEl.checked = false;
editTitleEl.value = movie.title || '';
editRatingEl.value = movie.rating || '';
editPosterEl.value = movie.poster || '';
editDescriptionEl.value = movie.description || '';
if (editMovieModalTitleEl) {
const label = movie.title ? `Modifier: ${movie.title}` : 'Modifier le film';
editMovieModalTitleEl.textContent = label;
}
editMovieModal.show();
}
async function saveMovieChanges() {
const movieId = (editMovieIdEl.value || '').trim();
if (!movieId) return;
const imdb_id = (editImdbIdEl.value || '').trim();
const yearRaw = (editYearEl.value || '').trim();
const category = (editCategoryEl.value || '').trim();
const vod_link = (editVodLinkEl.value || '').trim();
const refreshOmdb = !!editRefreshOmdbEl.checked;
const title = editTitleEl.value;
const rating = editRatingEl.value;
const poster = editPosterEl.value;
const description = editDescriptionEl.value;
if (!isValidImdbId(imdb_id)) {
showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567');
return;
}
let year = null;
if (yearRaw !== '') {
year = parseYear(yearRaw);
if (!year) {
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
return;
}
}
if (!category) {
showResponse('warning', 'Catégorie invalide.');
return;
}
if (!vod_link) {
showResponse('warning', 'Lien VOD invalide.');
return;
}
const body = {
imdb_id,
year: yearRaw === '' ? null : year,
category,
vod_link,
refreshOmdb
};
// Only send manual fields if we're not refreshing from OMDb
if (!refreshOmdb) {
body.title = title;
body.description = description;
body.rating = rating;
body.poster = poster;
}
try {
saveMovieChangesBtn.disabled = true;
saveMovieChangesBtn.textContent = 'Enregistrement...';
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
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;
}
const updated = await res.json();
moviesById.set(updated._id, updated);
showResponse('success', 'Film mis à jour avec succès.');
editMovieModal.hide();
await refreshYears();
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
saveMovieChangesBtn.disabled = false;
saveMovieChangesBtn.textContent = 'Enregistrer';
}
}
async function loadMoviesForManagement() { async function loadMoviesForManagement() {
const year = manageYearSelect.value; const year = manageYearSelect.value;
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies'; const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-muted">Chargement…</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-muted">Chargement…</td></tr>`;
try { try {
const res = await fetch(url, { method: 'GET' }); const res = await fetch(url, { method: 'GET' });
if (!res.ok) { if (!res.ok) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
return; return;
} }
const movies = await res.json(); const movies = await res.json();
movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' })); movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' }));
moviesById = new Map(movies.map((m) => [m._id, m]));
if (!movies.length) { if (!movies.length) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-muted">Aucun film.</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-muted">Aucun film.</td></tr>`;
updateSelectionUI(); updateSelectionUI();
return; return;
} }
@@ -188,6 +334,11 @@ window.onload = async function () {
</td> </td>
<td>${m.category || ''}</td> <td>${m.category || ''}</td>
<td>${m.year || ''}</td> <td>${m.year || ''}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-secondary" data-edit-movie-id="${m._id}">
Éditer
</button>
</td>
`; `;
adminMoviesBody.appendChild(tr); adminMoviesBody.appendChild(tr);
}); });
@@ -196,9 +347,13 @@ window.onload = async function () {
cb.addEventListener('change', updateSelectionUI); cb.addEventListener('change', updateSelectionUI);
}); });
adminMoviesBody.querySelectorAll('button[data-edit-movie-id]').forEach((btn) => {
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
});
updateSelectionUI(); updateSelectionUI();
} catch (err) { } catch (err) {
adminMoviesBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`; adminMoviesBody.innerHTML = `<tr><td colspan="5" class="text-danger">${err.message || 'Erreur réseau'}</td></tr>`;
} }
} }
@@ -249,6 +404,10 @@ window.onload = async function () {
} }
}); });
if (saveMovieChangesBtn) {
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
}
// Initial load // Initial load
await refreshYears(); await refreshYears();
await loadMoviesForManagement(); await loadMoviesForManagement();
+85
View File
@@ -14,6 +14,10 @@ function parseOscarYear(raw) {
return n; return n;
} }
function isValidImdbId(value) {
return typeof value === "string" && /^tt\d{5,}$/.test(value.trim());
}
// Fetch movie details from OMDb API // Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) { async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
@@ -233,4 +237,85 @@ router.delete("/delete", async (req, res) => {
} }
}); });
// Admin: update a movie (edit fields, optionally refresh from OMDb)
router.put("/:id", 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 { id } = req.params;
const {
imdb_id,
title,
description,
rating,
poster,
year,
category,
vod_link,
refreshOmdb,
} = req.body || {};
if (imdb_id !== undefined && imdb_id !== null && !isValidImdbId(imdb_id)) {
return res.status(400).json({ message: "IMDB ID invalide. Exemple attendu: tt1234567" });
}
if (year !== undefined && year !== null && year !== "") {
const parsedYear = parseOscarYear(year);
if (!parsedYear) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
}
if (typeof category === "string" && category.trim() === "") {
return res.status(400).json({ message: "Catégorie invalide." });
}
if (typeof vod_link === "string" && vod_link.trim() === "") {
return res.status(400).json({ message: "Lien VOD invalide." });
}
const movie = await Movie.findById(id);
if (!movie) return res.status(404).json({ message: "Movie not found" });
if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim();
if (category !== undefined) movie.category = String(category).trim();
if (vod_link !== undefined) movie.vod_link = String(vod_link).trim();
if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year);
} else if (year === null || year === "") {
// allow clearing the year explicitly
movie.year = null;
}
// If refreshOmdb is true, overwrite OMDb-derived fields from the imdb_id
if (refreshOmdb) {
const details = await fetchMovieDetailsFromOmdb(movie.imdb_id);
movie.title = details.title;
movie.description = details.description;
movie.rating = details.rating;
movie.poster = details.poster;
} else {
if (title !== undefined) movie.title = title;
if (description !== undefined) movie.description = description;
if (rating !== undefined) movie.rating = rating;
if (poster !== undefined) movie.poster = poster;
}
await movie.save();
return res.status(200).json(movie);
} 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; module.exports = router;