Allow clearing ratings and polish movies UI
Enable toggling ratings off by re-clicking the same star in the player and movie list, and tighten the movies card layout around rating and categories.
This commit is contained in:
+67
-3
@@ -234,27 +234,91 @@ footer {
|
|||||||
color: rgba(0,0,0,0.55);
|
color: rgba(0,0,0,0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movie-rating-avg {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-rating-sub--empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.movie-rating-block .imdb-icon {
|
.movie-rating-block .imdb-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
margin-left: 4px;
|
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 {
|
.movie-info-box {
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 8px 10px;
|
padding: 2px 10px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgba(0, 0, 0, 0.03);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
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 {
|
.movie-info-category {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: rgba(0, 0, 0, 0.65);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-style: italic;
|
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.movie-info-desc {
|
.movie-info-desc {
|
||||||
|
|||||||
+120
-2
@@ -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() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||||
@@ -412,6 +489,11 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
: '';
|
: '';
|
||||||
const userRatingOwn =
|
const userRatingOwn =
|
||||||
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
|
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 `<button type="button" class="movie-star${isActive ? ' is-active' : ''}" data-value="${value}" aria-label="${value} étoile${value > 1 ? 's' : ''}" aria-pressed="${isActive ? 'true' : 'false'}">★</button>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
// Navigation:
|
// Navigation:
|
||||||
// - Prefer in-app player when we have a non-legacy source
|
// - Prefer in-app player when we have a non-legacy source
|
||||||
@@ -490,20 +572,30 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
}
|
}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="movie-rating-block mt-1">
|
<div class="movie-rating-block mt-1">
|
||||||
${userRatingText ? `<div class="movie-rating-sub">Note utilisateurs ${userRatingText}</div>` : ''}
|
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
|
||||||
${userRatingOwn ? `<div class="movie-rating-sub">Votre note ${userRatingOwn}</div>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="movie-imdb-wrap">
|
||||||
<div class="movie-imdb-block">
|
<div class="movie-imdb-block">
|
||||||
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
|
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
|
||||||
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
|
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
|
||||||
<img src="/img/imdb.png" class="imdb-icon" alt="IMDb">
|
<img src="/img/imdb.png" class="imdb-icon" alt="IMDb">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="movie-rating-stars" role="group" aria-label="Noter ce film" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
|
||||||
|
${starsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
|
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
|
||||||
<div class="movie-info-box">
|
<div class="movie-info-box">
|
||||||
|
<details class="movie-info-categories">
|
||||||
|
<summary class="movie-info-summary">
|
||||||
|
<span class="movie-info-summary-title">Catégories nominés</span>
|
||||||
|
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
|
||||||
|
</summary>
|
||||||
<div class="movie-info-category">${movie.category}</div>
|
<div class="movie-info-category">${movie.category}</div>
|
||||||
|
</details>
|
||||||
<p class="movie-info-desc">${movie.description}</p>
|
<p class="movie-info-desc">${movie.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,6 +606,32 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
moviesList.appendChild(movieDiv);
|
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…');
|
pageLoader.setSubtitle('Finalisation de l’affichage…');
|
||||||
|
|||||||
+19
-1
@@ -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 } = {}) {
|
function initUserRating({ movieId, token, initial } = {}) {
|
||||||
const wrap = document.getElementById('user-rating');
|
const wrap = document.getElementById('user-rating');
|
||||||
const metaEl = document.getElementById('user-rating-meta');
|
const metaEl = document.getElementById('user-rating-meta');
|
||||||
@@ -613,6 +626,7 @@ function initUserRating({ movieId, token, initial } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentRating = Number.isFinite(Number(starting.userRating)) ? Number(starting.userRating) : null;
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
buttons.forEach((btn) => {
|
buttons.forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -624,8 +638,12 @@ function initUserRating({ movieId, token, initial } = {}) {
|
|||||||
buttons.forEach((b) => b.setAttribute('disabled', 'disabled'));
|
buttons.forEach((b) => b.setAttribute('disabled', 'disabled'));
|
||||||
metaEl.textContent = 'Enregistrement…';
|
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) {
|
if (result) {
|
||||||
|
currentRating = typeof result?.userRating === 'number' ? result.userRating : null;
|
||||||
applyRatingState({
|
applyRatingState({
|
||||||
wrap,
|
wrap,
|
||||||
buttons,
|
buttons,
|
||||||
|
|||||||
@@ -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)
|
// Get one movie by Mongo _id (used by the custom player page)
|
||||||
router.get("/:id", async (req, res) => {
|
router.get("/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user