Add user movie ratings and refresh movie cards

Store per-user 5-star ratings with averages and surface them in the player. Refresh movie list layout to show ratings and details in a cleaner card layout.
This commit is contained in:
Mathieu Moran
2026-01-25 16:16:07 -05:00
parent 406a4f0bb3
commit fe21a12c82
6 changed files with 436 additions and 29 deletions
+56
View File
@@ -208,6 +208,62 @@ footer {
object-fit: contain;
}
.movie-rating-block {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 2px;
font-size: 0.72rem;
line-height: 1.15;
color: rgba(0,0,0,0.6);
}
.movie-imdb-block {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
line-height: 1.1;
color: rgba(0,0,0,0.7);
white-space: nowrap;
}
.movie-rating-sub {
font-size: 0.7rem;
color: rgba(0,0,0,0.55);
}
.movie-rating-block .imdb-icon {
width: 18px;
height: 18px;
margin-left: 4px;
}
.movie-info-box {
margin-top: 10px;
margin-bottom: 10px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.movie-info-category {
font-size: 0.78rem;
color: rgba(0, 0, 0, 0.65);
font-weight: 600;
font-style: italic;
margin-bottom: 6px;
}
.movie-info-desc {
margin: 0;
font-size: 0.82rem;
line-height: 1.35;
color: rgba(0, 0, 0, 0.75);
}
.imdb-icon {
width: 26px;
height: 26px;
+39 -18
View File
@@ -403,6 +403,15 @@ window.addEventListener('DOMContentLoaded', async function () {
const legacyUrl = cleanUrl(movie?.vod_link);
const hasLegacy = !!legacyUrl;
const movieId = movie && movie._id ? String(movie._id) : '';
const userRatingAvg = Number(movie?.user_rating_avg);
const userRatingCount = Number(movie?.user_rating_count) || 0;
const userRating = Number(movie?.user_rating);
const userRatingText =
Number.isFinite(userRatingAvg) && userRatingAvg > 0
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
: '';
const userRatingOwn =
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
// Navigation:
// - Prefer in-app player when we have a non-legacy source
@@ -470,26 +479,38 @@ window.addEventListener('DOMContentLoaded', async function () {
: `${posterBlock}`
}
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start gap-2">
<h5 class="card-title mb-1 flex-grow-1">
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
: `<span class="text-dark">${movie.title}</span>`
}
</h5>
<div class="text-nowrap small text-muted">
⭐ ${movie.rating}
<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">
</a>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<h5 class="card-title mb-1">
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
: `<span class="text-dark">${movie.title}</span>`
}
</h5>
<div class="movie-rating-block mt-1">
${userRatingText ? `<div class="movie-rating-sub">Note utilisateurs ${userRatingText}</div>` : ''}
${userRatingOwn ? `<div class="movie-rating-sub">Votre note ${userRatingOwn}</div>` : ''}
</div>
</div>
<div class="movie-imdb-block">
${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">
<img src="/img/imdb.png" class="imdb-icon" alt="IMDb">
</a>
</div>
</div>
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
<div class="movie-info-box">
<div class="movie-info-category">${movie.category}</div>
<p class="movie-info-desc">${movie.description}</p>
</div>
</div>
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
<div class="text-muted small fw-semibold fst-italic mb-2">${movie.category}</div>
<p class="card-text small flex-grow-1 mb-2">${movie.description}</p>
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
${progressHtml}
<div class="mt-auto">
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div>
</div>`;
moviesList.appendChild(movieDiv);
+108
View File
@@ -546,6 +546,105 @@ function setHeader(movie, { activeYear } = {}) {
document.title = movie?.title ? `${titlePrefix} - ${movie.title}` : titlePrefix;
}
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;
}
function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount, userRating }) {
if (!wrap || !buttons?.length || !metaEl) return;
const avg = Number.isFinite(Number(averageRating)) ? Number(averageRating) : null;
const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0;
const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null;
buttons.forEach((btn) => {
const value = Number(btn.getAttribute('data-value'));
const isActive = user && value <= user;
btn.classList.toggle('is-active', !!isActive);
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
const parts = [];
if (avg && count > 0) {
parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`);
} else {
parts.push('Note utilisateurs: —');
}
metaEl.textContent = parts.join(' • ');
}
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;
}
}
function initUserRating({ movieId, token, initial } = {}) {
const wrap = document.getElementById('user-rating');
const metaEl = document.getElementById('user-rating-meta');
if (!wrap || !metaEl) return;
const buttons = Array.from(wrap.querySelectorAll('.star-btn'));
const starting = {
averageRating: initial?.averageRating ?? null,
ratingsCount: initial?.ratingsCount ?? 0,
userRating: initial?.userRating ?? null,
};
applyRatingState({ wrap, buttons, metaEl, ...starting });
if (!token) {
metaEl.textContent = 'Connecte-toi pour noter.';
buttons.forEach((btn) => btn.setAttribute('disabled', 'disabled'));
return;
}
let isSaving = false;
buttons.forEach((btn) => {
btn.addEventListener('click', async () => {
if (isSaving) return;
const value = normalizeRatingValue(btn.getAttribute('data-value'));
if (!value) return;
isSaving = true;
buttons.forEach((b) => b.setAttribute('disabled', 'disabled'));
metaEl.textContent = 'Enregistrement…';
const result = await saveUserRating(movieId, value, token);
if (result) {
applyRatingState({
wrap,
buttons,
metaEl,
averageRating: result?.averageRating,
ratingsCount: result?.ratingsCount,
userRating: result?.userRating,
});
} else {
applyRatingState({ wrap, buttons, metaEl, ...starting });
metaEl.textContent = 'Impossible d’enregistrer la note.';
}
buttons.forEach((b) => b.removeAttribute('disabled'));
isSaving = false;
});
});
}
function normalizeVodLink(raw) {
const v = String(raw || '').trim();
if (!v) return null;
@@ -1152,6 +1251,15 @@ window.onload = async function () {
const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]);
setHeader(movie, { activeYear });
initUserRating({
movieId: id,
token,
initial: {
averageRating: movie?.user_rating_avg,
ratingsCount: movie?.user_rating_count,
userRating: movie?.user_rating,
},
});
pageLoader.setProgress(58);
// Admin-only status block (Source + Progress) can be globally disabled via settings.
+47 -1
View File
@@ -106,6 +106,41 @@
outline-offset: 2px;
}
.user-rating {
display: flex;
flex-direction: column;
gap: .25rem;
align-items: center;
text-align: center;
width: 100%;
}
.rating-stars {
display: inline-flex;
align-items: center;
gap: .15rem;
justify-content: center;
}
.star-btn {
border: 0;
background: transparent;
color: rgba(0,0,0,0.25);
font-size: 1.1rem;
line-height: 1;
padding: .15rem .2rem;
}
.star-btn.is-active {
color: #f4b400;
}
.star-btn:focus-visible {
outline: 2px solid rgba(13,110,253,.35);
outline-offset: 2px;
border-radius: 4px;
}
#user-rating-meta {
color: rgba(0,0,0,0.6);
font-weight: 500;
}
/* Progress indicator: icon-first, minimal text */
.progress-indicator {
display: inline-flex;
@@ -195,7 +230,7 @@
</div>
</div>
<div class="d-flex flex-column gap-2 align-items-start align-items-lg-end">
<div class="d-flex flex-column gap-2 align-items-center align-items-lg-center">
<div id="quality-toggle" class="d-none quality-toggle">
<span class="quality-label">Qualité</span>
<div class="quality-pill" role="group" aria-label="Qualité">
@@ -204,6 +239,17 @@
</div>
</div>
<div id="user-rating" class="user-rating">
<div class="rating-stars" role="group" aria-label="Noter ce film">
<button type="button" class="star-btn" data-value="1" aria-label="1 étoile" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="2" aria-label="2 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="3" aria-label="3 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="4" aria-label="4 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="5" aria-label="5 étoiles" aria-pressed="false">★</button>
</div>
<div id="user-rating-meta" class="text-muted small">—</div>
</div>
<!-- Admin-only debug/status (Source + Progress) -->
<div id="player-admin-status" class="d-none d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2 align-items-center">