Admin movie editing feature #5

Merged
quoije merged 1 commits from cursor/admin-movie-editing-feature-b726 into 2026 2025-12-20 17:26:02 -05:00
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>
+172 -13
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); const opt = document.createElement('option');
if (!existingValues.has(value)) { opt.value = String(y);
const opt = document.createElement('option'); opt.textContent = String(y);
opt.value = value; manageYearSelect.appendChild(opt);
opt.textContent = value;
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;