diff --git a/public/css/app.css b/public/css/app.css index ed307b1..c38d719 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -234,27 +234,91 @@ footer { color: rgba(0,0,0,0.55); } + .movie-rating-avg { + margin-bottom: 5px; + } + + .movie-rating-sub--empty { + display: none; + } + .movie-rating-block .imdb-icon { width: 18px; height: 18px; margin-left: 4px; } + .movie-imdb-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; + } + + .movie-rating-stars { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + } + + .movie-star { + border: 0; + background: transparent; + color: rgba(0,0,0,0.25); + font-size: 0.9rem; + line-height: 1; + padding: 0 1px; + } + + .movie-star.is-active { + color: #f4b400; + } + .movie-info-box { - margin-top: 10px; + margin-top: 6px; margin-bottom: 10px; - padding: 8px 10px; + padding: 2px 10px 8px; border-radius: 10px; background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.06); } + .movie-info-categories { + margin: 0 0 2px 0; + } + + .movie-info-summary { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + list-style: none; + color: rgba(0, 0, 0, 0.65); + font-size: 0.78rem; + font-weight: 600; + } + + .movie-info-summary::-webkit-details-marker { + display: none; + } + + .movie-info-summary-arrow { + display: inline-block; + transition: transform 120ms ease; + } + + .movie-info-categories[open] .movie-info-summary-arrow { + transform: rotate(180deg); + } + .movie-info-category { font-size: 0.78rem; color: rgba(0, 0, 0, 0.65); font-weight: 600; - font-style: italic; margin-bottom: 6px; + margin-top: 6px; } .movie-info-desc { diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 7105f31..ac53294 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -195,6 +195,83 @@ window.addEventListener('DOMContentLoaded', async function () { `; } + function normalizeRatingValue(v) { + const n = Number(v); + if (!Number.isFinite(n)) return null; + const rounded = Math.round(n); + if (rounded < 1 || rounded > 5) return null; + return rounded; + } + + async function saveUserRating(movieId, rating, token) { + try { + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ rating }), + }); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch (_) { + return null; + } + } + + async function deleteUserRating(movieId, token) { + try { + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { + method: 'DELETE', + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, + }); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch (_) { + return null; + } + } + + function updateStarUi(wrap, rating) { + if (!wrap) return; + const value = Number(rating); + const user = Number.isFinite(value) ? value : null; + if (wrap?.dataset) { + wrap.dataset.currentRating = user ? String(user) : ''; + } + wrap.querySelectorAll('.movie-star').forEach((btn) => { + const starValue = Number(btn.getAttribute('data-value')); + const isActive = user && starValue <= user; + btn.classList.toggle('is-active', !!isActive); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + } + + function updateRatingTexts(cardEl, avgRating, ratingsCount, userRating) { + if (!cardEl) return; + const avgEl = cardEl.querySelector('[data-role="avg-rating"]'); + const userEl = cardEl.querySelector('[data-role="user-rating"]'); + const avg = Number.isFinite(Number(avgRating)) ? Number(avgRating) : null; + const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0; + const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null; + + if (avgEl) { + if (avg && count > 0) { + avgEl.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`; + avgEl.classList.remove('movie-rating-sub--empty'); + } else { + avgEl.textContent = ''; + avgEl.classList.add('movie-rating-sub--empty'); + } + } + + if (userEl) { + userEl.textContent = ''; + userEl.classList.add('movie-rating-sub--empty'); + } + } + async function fetchActiveYear() { try { const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' }); @@ -412,6 +489,11 @@ window.addEventListener('DOMContentLoaded', async function () { : ''; const userRatingOwn = Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : ''; + const starsHtml = Array.from({ length: 5 }, (_, idx) => { + const value = idx + 1; + const isActive = Number.isFinite(userRating) && userRating >= value; + return ``; + }).join(''); // Navigation: // - Prefer in-app player when we have a non-legacy source @@ -490,20 +572,30 @@ window.addEventListener('DOMContentLoaded', async function () { }
- ${userRatingText ? `
Note utilisateurs ${userRatingText}
` : ''} - ${userRatingOwn ? `
Votre note ${userRatingOwn}
` : ''} +
${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}
-
- ${movie.rating ? `⭐ ${movie.rating}` : ''} - - IMDb - +
+
+ ${movie.rating ? `⭐ ${movie.rating}` : ''} + + IMDb + +
+
+ ${starsHtml} +
${!isClickable ? '
Aucune source
' : ''}
-
${movie.category}
+
+ + Catégories nominés + + +
${movie.category}
+

${movie.description}

@@ -514,6 +606,32 @@ window.addEventListener('DOMContentLoaded', async function () { `; moviesList.appendChild(movieDiv); + + const starsWrap = movieDiv.querySelector('.movie-rating-stars'); + if (starsWrap) { + if (!token || !movieId) { + starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled')); + } else { + starsWrap.addEventListener('click', async (event) => { + const target = event.target; + if (!target || !target.classList.contains('movie-star')) return; + const value = normalizeRatingValue(target.getAttribute('data-value')); + if (!value) return; + + starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled')); + const current = Number(starsWrap.dataset.currentRating || ''); + const shouldClear = current && current === value; + const result = shouldClear + ? await deleteUserRating(movieId, token) + : await saveUserRating(movieId, value, token); + if (result) { + updateStarUi(starsWrap, result?.userRating); + updateRatingTexts(movieDiv, result?.averageRating, result?.ratingsCount, result?.userRating); + } + starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.removeAttribute('disabled')); + }); + } + } }); pageLoader.setSubtitle('Finalisation de l’affichage…'); diff --git a/public/js/app_player.js b/public/js/app_player.js index ef5018d..e9f15f6 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -593,6 +593,19 @@ async function saveUserRating(movieId, rating, token) { } } +async function deleteUserRating(movieId, token) { + try { + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { + method: 'DELETE', + headers: token ? { 'Authorization': `Bearer ${token}` } : undefined, + }); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch (_) { + return null; + } +} + function initUserRating({ movieId, token, initial } = {}) { const wrap = document.getElementById('user-rating'); const metaEl = document.getElementById('user-rating-meta'); @@ -613,6 +626,7 @@ function initUserRating({ movieId, token, initial } = {}) { return; } + let currentRating = Number.isFinite(Number(starting.userRating)) ? Number(starting.userRating) : null; let isSaving = false; buttons.forEach((btn) => { btn.addEventListener('click', async () => { @@ -624,8 +638,12 @@ function initUserRating({ movieId, token, initial } = {}) { buttons.forEach((b) => b.setAttribute('disabled', 'disabled')); metaEl.textContent = 'Enregistrement…'; - const result = await saveUserRating(movieId, value, token); + const shouldClear = currentRating && value === currentRating; + const result = shouldClear + ? await deleteUserRating(movieId, token) + : await saveUserRating(movieId, value, token); if (result) { + currentRating = typeof result?.userRating === 'number' ? result.userRating : null; applyRatingState({ wrap, buttons, diff --git a/routes/movies.js b/routes/movies.js index 076f53c..be918a9 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -1284,6 +1284,39 @@ router.put("/:id/ratings", authenticate, async (req, res) => { } }); +// Remove a user rating +router.delete("/:id/ratings", authenticate, async (req, res) => { + try { + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid movie id" }); + } + const movieId = new mongoose.Types.ObjectId(id); + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "User not found" }); + + await MovieRating.deleteOne({ movieId, userId }); + + const agg = await MovieRating.aggregate([ + { $match: { movieId } }, + { $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }, + ]); + + const avg = agg?.[0]?.avgRating; + const count = agg?.[0]?.count; + + res.set("Cache-Control", "private, no-store, must-revalidate"); + res.set("Vary", "Authorization"); + return res.status(200).json({ + averageRating: typeof avg === "number" ? Number(avg.toFixed(2)) : null, + ratingsCount: typeof count === "number" ? count : 0, + userRating: null, + }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + // Get one movie by Mongo _id (used by the custom player page) router.get("/:id", async (req, res) => { try {