Merge pull request #5 from quoije/cursor/admin-movie-editing-feature-b726
Admin movie editing feature
This commit was merged in pull request #5.
This commit is contained in:
+70
-1
@@ -122,16 +122,85 @@
|
||||
<th>Titre</th>
|
||||
<th style="width: 20%;">Catégorie</th>
|
||||
<th style="width: 12%;">Année</th>
|
||||
<th style="width: 1%;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</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="./js/app_control.js"></script>
|
||||
|
||||
|
||||
+172
-13
@@ -40,6 +40,24 @@ window.onload = async function () {
|
||||
const selectAllBox = document.getElementById('select-all-movies');
|
||||
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) {
|
||||
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
|
||||
responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
|
||||
@@ -53,6 +71,10 @@ window.onload = async function () {
|
||||
return n;
|
||||
}
|
||||
|
||||
function isValidImdbId(value) {
|
||||
return /^tt\d{5,}$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
document.getElementById('reset-form').addEventListener('click', function () {
|
||||
form.reset();
|
||||
responseEl.classList.add('d-none');
|
||||
@@ -117,18 +139,24 @@ window.onload = async function () {
|
||||
const yearsRes = await fetch('/api/movies/years');
|
||||
const years = yearsRes.ok ? await yearsRes.json() : [];
|
||||
|
||||
// Populate both the add form (default) and manage dropdown
|
||||
const existingValues = new Set([...manageYearSelect.options].map((o) => o.value));
|
||||
// Rebuild manage dropdown from scratch (so removed years disappear)
|
||||
const previousSelection = manageYearSelect.value;
|
||||
manageYearSelect.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = 'Toutes';
|
||||
manageYearSelect.appendChild(allOpt);
|
||||
|
||||
years.forEach((y) => {
|
||||
const value = String(y);
|
||||
if (!existingValues.has(value)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = value;
|
||||
manageYearSelect.appendChild(opt);
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(y);
|
||||
opt.textContent = String(y);
|
||||
manageYearSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const stillExists = years.map(String).includes(previousSelection);
|
||||
manageYearSelect.value = stillExists ? previousSelection : '';
|
||||
|
||||
// Default add-year to latest year if empty
|
||||
if (years.length > 0 && !yearInput.value) {
|
||||
yearInput.value = String(years[0]);
|
||||
@@ -155,22 +183,140 @@ window.onload = async function () {
|
||||
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() {
|
||||
const year = manageYearSelect.value;
|
||||
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 {
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
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;
|
||||
}
|
||||
const movies = await res.json();
|
||||
movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' }));
|
||||
moviesById = new Map(movies.map((m) => [m._id, m]));
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -188,6 +334,11 @@ window.onload = async function () {
|
||||
</td>
|
||||
<td>${m.category || ''}</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);
|
||||
});
|
||||
@@ -196,9 +347,13 @@ window.onload = async function () {
|
||||
cb.addEventListener('change', updateSelectionUI);
|
||||
});
|
||||
|
||||
adminMoviesBody.querySelectorAll('button[data-edit-movie-id]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
|
||||
});
|
||||
|
||||
updateSelectionUI();
|
||||
} 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
|
||||
await refreshYears();
|
||||
await loadMoviesForManagement();
|
||||
|
||||
@@ -14,6 +14,10 @@ function parseOscarYear(raw) {
|
||||
return n;
|
||||
}
|
||||
|
||||
function isValidImdbId(value) {
|
||||
return typeof value === "string" && /^tt\d{5,}$/.test(value.trim());
|
||||
}
|
||||
|
||||
// Fetch movie details from OMDb API
|
||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user