Statistics page redesign #60
+80
-80
@@ -101,96 +101,96 @@
|
|||||||
|
|
||||||
<div class="tab-content pt-3">
|
<div class="tab-content pt-3">
|
||||||
<div class="tab-pane fade show active" id="admin-settings" role="tabpanel" aria-labelledby="admin-settings-tab">
|
<div class="tab-pane fade show active" id="admin-settings" role="tabpanel" aria-labelledby="admin-settings-tab">
|
||||||
<div class="row g-3">
|
<div class="card p-3">
|
||||||
<div class="col-12 col-xxl-8">
|
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
|
||||||
<div class="accordion" id="adminSettingsAccordion">
|
<div>
|
||||||
<!-- Active year -->
|
<h4 class="mb-1">Réglages</h4>
|
||||||
<div class="accordion-item">
|
<div class="text-muted">Paramètres globaux du site (année, dates, options du lecteur).</div>
|
||||||
<h2 class="accordion-header" id="adminSettingsHeadingYear">
|
</div>
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapseYear" aria-expanded="true" aria-controls="adminSettingsCollapseYear">
|
</div>
|
||||||
Année active
|
|
||||||
</button>
|
<ul class="nav nav-pills admin-subtabs mt-3" id="adminSettingsSubTabs" role="tablist">
|
||||||
</h2>
|
<li class="nav-item" role="presentation">
|
||||||
<div id="adminSettingsCollapseYear" class="accordion-collapse collapse show" aria-labelledby="adminSettingsHeadingYear" data-bs-parent="#adminSettingsAccordion">
|
<button class="nav-link active" id="admin-settings-subtab-year" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-year" type="button" role="tab" aria-controls="admin-settings-pane-year" aria-selected="true">
|
||||||
<div class="accordion-body">
|
Année active
|
||||||
<div class="text-muted small mb-2">
|
</button>
|
||||||
Pilote tout le site (films, checklist, statistiques).
|
</li>
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-end justify-content-between">
|
<button class="nav-link" id="admin-settings-subtab-player" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-player" type="button" role="tab" aria-controls="admin-settings-pane-player" aria-selected="false">
|
||||||
<div class="flex-grow-1" style="min-width: 220px;">
|
Lecteur
|
||||||
<label for="active_oscar_year" class="form-label mb-1">Année active</label>
|
</button>
|
||||||
<select class="form-select" id="active_oscar_year"></select>
|
</li>
|
||||||
<div class="form-text">Basé sur les années présentes dans les films.</div>
|
<li class="nav-item" role="presentation">
|
||||||
</div>
|
<button class="nav-link" id="admin-settings-subtab-oscar-date" data-bs-toggle="tab" data-bs-target="#admin-settings-pane-oscar-date" type="button" role="tab" aria-controls="admin-settings-pane-oscar-date" aria-selected="false">
|
||||||
<div class="d-flex justify-content-end">
|
Date Oscars
|
||||||
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content pt-3">
|
||||||
|
<!-- Active year -->
|
||||||
|
<div class="tab-pane fade show active" id="admin-settings-pane-year" role="tabpanel" aria-labelledby="admin-settings-subtab-year" tabindex="0">
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<div class="text-muted small mb-3">
|
||||||
|
Pilote tout le site (films, checklist, statistiques).
|
||||||
|
</div>
|
||||||
|
<label for="active_oscar_year" class="form-label">Année active</label>
|
||||||
|
<div class="admin-settings-narrow">
|
||||||
|
<div class="input-group">
|
||||||
|
<select class="form-select" id="active_oscar_year"></select>
|
||||||
|
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text">Basé sur les années présentes dans les films.</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Player -->
|
<!-- Player -->
|
||||||
<div class="accordion-item">
|
<div class="tab-pane fade" id="admin-settings-pane-player" role="tabpanel" aria-labelledby="admin-settings-subtab-player" tabindex="0">
|
||||||
<h2 class="accordion-header" id="adminSettingsHeadingPlayer">
|
<div class="border rounded p-3 bg-light">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapsePlayer" aria-expanded="false" aria-controls="adminSettingsCollapsePlayer">
|
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||||
Lecteur (player)
|
<div class="form-check">
|
||||||
</button>
|
<input class="form-check-input" type="checkbox" id="player_ui_show_status">
|
||||||
</h2>
|
<label class="form-check-label" for="player_ui_show_status">Afficher “Source” + “Progress” (admins)</label>
|
||||||
<div id="adminSettingsCollapsePlayer" class="accordion-collapse collapse" aria-labelledby="adminSettingsHeadingPlayer" data-bs-parent="#adminSettingsAccordion">
|
</div>
|
||||||
<div class="accordion-body">
|
<div class="d-flex gap-2">
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
<button type="button" id="player_ui_reload" class="btn btn-outline-secondary">Recharger</button>
|
||||||
<div class="form-check">
|
<button type="button" id="player_ui_save" class="btn btn-warning">Enregistrer</button>
|
||||||
<input class="form-check-input" type="checkbox" id="player_ui_show_status">
|
|
||||||
<label class="form-check-label" for="player_ui_show_status">Afficher “Source” + “Progress” (admins)</label>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" id="player_ui_reload" class="btn btn-outline-secondary btn-sm">Recharger</button>
|
|
||||||
<button type="button" id="player_ui_save" class="btn btn-warning btn-sm">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-text mt-2">
|
|
||||||
Contrôle l’affichage du bloc debug dans <code>player.html</code> (admins uniquement).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
Contrôle l’affichage du bloc debug dans <code>player.html</code> (admins uniquement).
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Oscar date -->
|
<!-- Oscar date -->
|
||||||
<div class="accordion-item">
|
<div class="tab-pane fade" id="admin-settings-pane-oscar-date" role="tabpanel" aria-labelledby="admin-settings-subtab-oscar-date" tabindex="0">
|
||||||
<h2 class="accordion-header" id="adminSettingsHeadingOscarDate">
|
<div class="border rounded p-3 bg-light">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminSettingsCollapseOscarDate" aria-expanded="false" aria-controls="adminSettingsCollapseOscarDate">
|
<div class="text-muted small mb-3">
|
||||||
Date des Oscars (par année)
|
Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: <code>15 mars</code>.
|
||||||
</button>
|
</div>
|
||||||
</h2>
|
|
||||||
<div id="adminSettingsCollapseOscarDate" class="accordion-collapse collapse" aria-labelledby="adminSettingsHeadingOscarDate" data-bs-parent="#adminSettingsAccordion">
|
|
||||||
<div class="accordion-body">
|
|
||||||
<div class="text-muted small mb-2">
|
|
||||||
Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: <code>15 mars</code>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2">
|
<div class="row g-3 align-items-end">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-4">
|
||||||
<label for="oscar_date_year" class="form-label mb-1">Année</label>
|
<label for="oscar_date_year" class="form-label">Année</label>
|
||||||
<select class="form-select" id="oscar_date_year"></select>
|
<select class="form-select" id="oscar_date_year"></select>
|
||||||
<div class="form-text">Basé sur les années présentes dans les films.</div>
|
<div class="form-text">Basé sur les années présentes dans les films.</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4">
|
|
||||||
<label for="oscar_date_value" class="form-label mb-1">Date</label>
|
|
||||||
<input type="date" class="form-control" id="oscar_date_value">
|
|
||||||
<div class="form-text">Format: YYYY-MM-DD.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4 d-flex align-items-end justify-content-end gap-2">
|
|
||||||
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger btn-sm">Effacer</button>
|
|
||||||
<button type="button" id="save-oscar-date" class="btn btn-warning btn-sm">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="text-muted small mb-1">Dates configurées</div>
|
|
||||||
<div id="oscar-date-current" class="border rounded p-2 bg-light">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<label for="oscar_date_value" class="form-label">Date</label>
|
||||||
|
<input type="date" class="form-control" id="oscar_date_value">
|
||||||
|
<div class="form-text">Format: YYYY-MM-DD.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 d-flex gap-2 justify-content-end">
|
||||||
|
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger w-100">Effacer</button>
|
||||||
|
<button type="button" id="save-oscar-date" class="btn btn-warning w-100">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="text-muted small mb-1">Dates configurées</div>
|
||||||
|
<div id="oscar-date-current" class="border rounded p-2 bg-white">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,3 +219,116 @@ footer {
|
|||||||
.watched-badge {
|
.watched-badge {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats → "Films regardés" modal */
|
||||||
|
.stats-movies-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-item--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-item--clickable:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-color: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-poster {
|
||||||
|
width: 56px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-poster.stats-movie-poster--empty {
|
||||||
|
background: linear-gradient(135deg, rgba(0,0,0,0.08), rgba(0,0,0,0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-title {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details summary::before {
|
||||||
|
content: "›";
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 6px;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details[open] summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-details-body {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-left: 14px;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-imdb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-movie-imdb .imdb-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin → Settings: keep controls compact */
|
||||||
|
.admin-settings-narrow {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.admin-settings-narrow {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,7 +159,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchCompletionModalContent() {
|
async function fetchCompletionModalContent() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
|
const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch completion modal');
|
if (!res.ok) throw new Error('Failed to fetch completion modal');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return {
|
return {
|
||||||
@@ -217,7 +217,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const year = Number(data?.year);
|
const year = Number(data?.year);
|
||||||
@@ -229,7 +229,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchOscarEffectiveDate(year) {
|
async function fetchOscarEffectiveDate(year) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
|
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
||||||
@@ -247,7 +247,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
async function fetchMoviesSummary(year) {
|
async function fetchMoviesSummary(year) {
|
||||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
@@ -346,7 +347,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
fetchCompletionModalContent(),
|
fetchCompletionModalContent(),
|
||||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
|
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -498,7 +500,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
|
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updatedWatchedMoviesRes.ok) {
|
if (updatedWatchedMoviesRes.ok) {
|
||||||
|
|||||||
+12
-20
@@ -165,7 +165,7 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlayerUi() {
|
async function fetchPlayerUi() {
|
||||||
const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' });
|
const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET', cache: 'no-store' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = data.message || data.error || `Erreur (${res.status})`;
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
@@ -254,7 +254,7 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAppVersionState() {
|
async function fetchAppVersionState() {
|
||||||
const res = await fetch('/api/settings/app-version', { method: 'GET' });
|
const res = await fetch('/api/settings/app-version', { method: 'GET', cache: 'no-store' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = data.message || data.error || `Erreur (${res.status})`;
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
@@ -338,7 +338,7 @@ window.onload = async function () {
|
|||||||
async function refreshAppVersionPreview() {
|
async function refreshAppVersionPreview() {
|
||||||
if (!appVersionPreviewEl) return;
|
if (!appVersionPreviewEl) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/version', { method: 'GET' });
|
const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' });
|
||||||
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
const line = `${data?.date || ''} - ${data?.version || ''} - ${data?.message || ''}`.trim();
|
const line = `${data?.date || ''} - ${data?.version || ''} - ${data?.message || ''}`.trim();
|
||||||
@@ -462,7 +462,7 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCompletionModal() {
|
async function fetchCompletionModal() {
|
||||||
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
|
const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-store' });
|
||||||
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
if (!res.ok) throw new Error(`Erreur (${res.status})`);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
return normalizeCompletionModal(data);
|
return normalizeCompletionModal(data);
|
||||||
@@ -763,7 +763,8 @@ window.onload = async function () {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ([]));
|
const data = await res.json().catch(() => ([]));
|
||||||
@@ -805,7 +806,7 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOscarDates() {
|
async function fetchOscarDates() {
|
||||||
const res = await fetch('/api/settings/oscar-dates', { method: 'GET' });
|
const res = await fetch('/api/settings/oscar-dates', { method: 'GET', cache: 'no-store' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = data.message || data.error || `Erreur (${res.status})`;
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
@@ -876,7 +877,7 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWinners() {
|
async function fetchWinners() {
|
||||||
const res = await fetch('/api/settings/winners', { method: 'GET' });
|
const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-store' });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = data.message || data.error || `Erreur (${res.status})`;
|
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||||
@@ -1129,7 +1130,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-store' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const year = Number(data?.year);
|
const year = Number(data?.year);
|
||||||
@@ -1536,6 +1537,7 @@ window.onload = async function () {
|
|||||||
const res = await fetch('/api/admin/db/backups', {
|
const res = await fetch('/api/admin/db/backups', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1935,11 +1937,6 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
|
||||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/movies/add', {
|
const res = await fetch('/api/movies/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1973,7 +1970,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
async function refreshYears() {
|
async function refreshYears() {
|
||||||
try {
|
try {
|
||||||
const yearsRes = await fetch('/api/movies/years');
|
const yearsRes = await fetch('/api/movies/years', { cache: 'no-store' });
|
||||||
const years = yearsRes.ok ? await yearsRes.json() : [];
|
const years = yearsRes.ok ? await yearsRes.json() : [];
|
||||||
const cleanedYears = (Array.isArray(years) ? years : [])
|
const cleanedYears = (Array.isArray(years) ? years : [])
|
||||||
.map((y) => Number(y))
|
.map((y) => Number(y))
|
||||||
@@ -2187,11 +2184,6 @@ window.onload = async function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
|
||||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
imdb_id,
|
imdb_id,
|
||||||
year: yearRaw === '' ? null : year,
|
year: yearRaw === '' ? null : year,
|
||||||
@@ -2255,7 +2247,7 @@ window.onload = async function () {
|
|||||||
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Chargement…</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Chargement…</td></tr>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'GET' });
|
const res = await fetch(url, { method: 'GET', cache: 'no-store' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
+37
-13
@@ -162,7 +162,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const year = Number(data?.year);
|
const year = Number(data?.year);
|
||||||
@@ -174,7 +174,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchOscarEffectiveDate(year) {
|
async function fetchOscarEffectiveDate(year) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
|
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
if (!res.ok) throw new Error('Failed to fetch oscar date');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
|
||||||
@@ -194,6 +194,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
if (!res.ok) throw new Error('Failed to fetch movies summary');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
@@ -319,6 +320,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -348,15 +350,26 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
||||||
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
|
||||||
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||||
const hasVideoSrc = !!(movie && typeof movie.video_src === 'string' && movie.video_src.trim());
|
const cleanUrl = (v) => {
|
||||||
const hasEmbedSrc = !!(movie && typeof movie.embed_src === 'string' && movie.embed_src.trim());
|
const s = (typeof v === 'string' ? v.trim() : '');
|
||||||
|
// Treat placeholders as "no link" so you never need to store '#'
|
||||||
|
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const hasVideoSrc = !!cleanUrl(movie?.video_src);
|
||||||
|
const hasEmbedSrc = !!cleanUrl(movie?.embed_src);
|
||||||
const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
|
const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
|
||||||
const hasLegacy = !!(movie && typeof movie.vod_link === 'string' && movie.vod_link.trim());
|
const legacyUrl = cleanUrl(movie?.vod_link);
|
||||||
|
const hasLegacy = !!legacyUrl;
|
||||||
|
|
||||||
// If the movie only has legacy `vod_link`, skip the player entirely and open the original URL in a new tab.
|
// Navigation:
|
||||||
|
// - Prefer in-app player when we have a non-legacy source
|
||||||
|
// - If only legacy exists, open the legacy URL
|
||||||
|
// - If there is no source at all, render as non-clickable (no "#")
|
||||||
const playerUrl = (movie && movie._id && hasNewPlayerSource)
|
const playerUrl = (movie && movie._id && hasNewPlayerSource)
|
||||||
? `/player.html?id=${encodeURIComponent(movie._id)}`
|
? `/player.html?id=${encodeURIComponent(movie._id)}`
|
||||||
: (hasLegacy ? movie.vod_link : (movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : '#'));
|
: (hasLegacy ? legacyUrl : '');
|
||||||
|
const isClickable = !!playerUrl;
|
||||||
|
|
||||||
const movieDiv = document.createElement('div');
|
const movieDiv = document.createElement('div');
|
||||||
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
||||||
@@ -364,15 +377,25 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
movieDiv.innerHTML = `
|
movieDiv.innerHTML = `
|
||||||
<div class="card h-100 shadow-sm">
|
<div class="card h-100 shadow-sm">
|
||||||
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
|
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
|
||||||
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
|
${
|
||||||
<div class="text-center pt-3 px-3">
|
isClickable
|
||||||
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
|
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
|
||||||
</div>
|
<div class="text-center pt-3 px-3">
|
||||||
</a>
|
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
|
||||||
|
</div>
|
||||||
|
</a>`
|
||||||
|
: `<div class="text-center pt-3 px-3">
|
||||||
|
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||||
<h5 class="card-title mb-1 flex-grow-1">
|
<h5 class="card-title mb-1 flex-grow-1">
|
||||||
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>
|
${
|
||||||
|
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>
|
</h5>
|
||||||
<div class="text-nowrap small text-muted">
|
<div class="text-nowrap small text-muted">
|
||||||
⭐ ${movie.rating}
|
⭐ ${movie.rating}
|
||||||
@@ -381,6 +404,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
<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>` : ''}
|
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||||
|
|||||||
+292
-49
@@ -130,7 +130,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchActiveYear() {
|
async function fetchActiveYear() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/year', { method: 'GET' });
|
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch active year');
|
if (!res.ok) throw new Error('Failed to fetch active year');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const year = Number(data?.year);
|
const year = Number(data?.year);
|
||||||
@@ -142,7 +142,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
async function fetchWinners() {
|
async function fetchWinners() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/winners', { method: 'GET' });
|
const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-cache' });
|
||||||
if (!res.ok) throw new Error('Failed to fetch winners');
|
if (!res.ok) throw new Error('Failed to fetch winners');
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
return Array.isArray(data?.winners) ? data.winners : [];
|
return Array.isArray(data?.winners) ? data.winners : [];
|
||||||
@@ -155,7 +155,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/completions', {
|
const res = await fetch('/api/users/completions', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to fetch completions');
|
if (!res.ok) throw new Error('Failed to fetch completions');
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -253,7 +254,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
if (!completions || typeof completions !== 'object') {
|
if (!completions || typeof completions !== 'object') {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-muted">Impossible de charger les finisseurs 100%.</td>
|
<td colspan="3" class="text-muted">Impossible de charger les finisseurs 100%.</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -266,7 +267,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
if (!years.length) {
|
if (!years.length) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-muted">Aucune année trouvée.</td>
|
<td colspan="3" class="text-muted">Aucune année trouvée.</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -282,7 +283,6 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const yearStr = String(y);
|
const yearStr = String(y);
|
||||||
const total = Number(totals?.[yearStr] ?? 0);
|
const total = Number(totals?.[yearStr] ?? 0);
|
||||||
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
|
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
|
||||||
const count = completers.length;
|
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
@@ -290,17 +290,11 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
tdYear.className = 'fw-semibold';
|
tdYear.className = 'fw-semibold';
|
||||||
tdYear.textContent = yearStr;
|
tdYear.textContent = yearStr;
|
||||||
|
|
||||||
const tdCount = document.createElement('td');
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'badge bg-secondary';
|
|
||||||
badge.textContent = String(count);
|
|
||||||
tdCount.appendChild(badge);
|
|
||||||
|
|
||||||
const tdTotal = document.createElement('td');
|
const tdTotal = document.createElement('td');
|
||||||
tdTotal.textContent = String(Number.isFinite(total) ? total : 0);
|
tdTotal.textContent = String(Number.isFinite(total) ? total : 0);
|
||||||
|
|
||||||
const tdNames = document.createElement('td');
|
const tdNames = document.createElement('td');
|
||||||
if (!count) {
|
if (!completers.length) {
|
||||||
tdNames.className = 'text-muted';
|
tdNames.className = 'text-muted';
|
||||||
tdNames.textContent = '—';
|
tdNames.textContent = '—';
|
||||||
} else {
|
} else {
|
||||||
@@ -316,7 +310,6 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tr.appendChild(tdYear);
|
tr.appendChild(tdYear);
|
||||||
tr.appendChild(tdCount);
|
|
||||||
tr.appendChild(tdTotal);
|
tr.appendChild(tdTotal);
|
||||||
tr.appendChild(tdNames);
|
tr.appendChild(tdNames);
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -356,6 +349,262 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
try {
|
try {
|
||||||
pageLoader.setProgress(12);
|
pageLoader.setProgress(12);
|
||||||
|
|
||||||
|
// Modal UI (Films regardés)
|
||||||
|
const moviesModalEl = document.getElementById('moviesModal');
|
||||||
|
const movieGridEl = document.getElementById('movie-grid');
|
||||||
|
const moviesSummaryEl = document.getElementById('movies-summary');
|
||||||
|
const moviesEmptyEl = document.getElementById('movies-empty');
|
||||||
|
const moviesModalTitleEl = document.getElementById('moviesModalLabel');
|
||||||
|
|
||||||
|
let currentModalUserName = '';
|
||||||
|
let currentModalMovies = [];
|
||||||
|
|
||||||
|
function safeStr(v) {
|
||||||
|
return (v === null || v === undefined) ? '' : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMaybeNumber(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWatchedDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
try {
|
||||||
|
return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
} catch (_) {
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePosterUrl(poster) {
|
||||||
|
const s = safeStr(poster).trim();
|
||||||
|
if (!s || s.toUpperCase() === 'N/A') return '';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMoviesRecent(list) {
|
||||||
|
const arr = Array.isArray(list) ? list.slice() : [];
|
||||||
|
// watchedDate desc, fallback title
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const at = a?.watchedDate ? new Date(a.watchedDate).getTime() : 0;
|
||||||
|
const bt = b?.watchedDate ? new Date(b.watchedDate).getTime() : 0;
|
||||||
|
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at;
|
||||||
|
return safeStr(a?.title).localeCompare(safeStr(b?.title), 'fr', { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMoviesModal() {
|
||||||
|
if (!movieGridEl || !moviesSummaryEl || !moviesEmptyEl) return;
|
||||||
|
|
||||||
|
const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : []));
|
||||||
|
|
||||||
|
const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0;
|
||||||
|
moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film';
|
||||||
|
|
||||||
|
movieGridEl.innerHTML = '';
|
||||||
|
if (!sorted.length) {
|
||||||
|
moviesEmptyEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moviesEmptyEl.classList.add('d-none');
|
||||||
|
|
||||||
|
sorted.forEach((movie) => {
|
||||||
|
const movieId = safeStr(movie?.movieId).trim();
|
||||||
|
const imdbId = safeStr(movie?.imdb_id).trim();
|
||||||
|
const title = safeStr(movie?.title).trim() || '(sans titre)';
|
||||||
|
const category = safeStr(movie?.category).trim();
|
||||||
|
const ratingRaw = safeStr(movie?.rating).trim();
|
||||||
|
const rating = parseMaybeNumber(ratingRaw);
|
||||||
|
const watchedLabel = formatWatchedDate(movie?.watchedDate);
|
||||||
|
const posterUrl = normalizePosterUrl(movie?.poster);
|
||||||
|
|
||||||
|
const cleanUrl = (v) => {
|
||||||
|
const s = (typeof v === 'string' ? v.trim() : '');
|
||||||
|
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Click behavior (requested):
|
||||||
|
// - If vod_link is set => go to it
|
||||||
|
// - Else if any playable source exists => go to player
|
||||||
|
// - Else => not clickable
|
||||||
|
const vodLink = cleanUrl(movie?.vod_link);
|
||||||
|
const hasPlayableSource = !!cleanUrl(movie?.video_src) || !!cleanUrl(movie?.embed_src) || !!cleanUrl(movie?.video_file);
|
||||||
|
const isClickable = !!vodLink || (!!movieId && hasPlayableSource);
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'stats-movie-item';
|
||||||
|
if (isClickable) {
|
||||||
|
item.classList.add('stats-movie-item--clickable');
|
||||||
|
item.setAttribute('role', 'link');
|
||||||
|
item.setAttribute('tabindex', '0');
|
||||||
|
item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = document.createElement('img');
|
||||||
|
poster.className = 'stats-movie-poster';
|
||||||
|
poster.alt = title;
|
||||||
|
if (posterUrl) {
|
||||||
|
poster.src = posterUrl;
|
||||||
|
} else {
|
||||||
|
poster.classList.add('stats-movie-poster--empty');
|
||||||
|
// Keep src empty; browser will not request anything.
|
||||||
|
poster.src = '';
|
||||||
|
}
|
||||||
|
poster.addEventListener('error', () => {
|
||||||
|
poster.classList.add('stats-movie-poster--empty');
|
||||||
|
try { poster.removeAttribute('src'); } catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openIfPossible(evt) {
|
||||||
|
if (!isClickable) return;
|
||||||
|
// Don't hijack clicks inside controls (IMDb button, details summary, etc.)
|
||||||
|
const target = evt?.target;
|
||||||
|
if (target && typeof target.closest === 'function') {
|
||||||
|
if (target.closest('a, button, summary, details')) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vodLink) {
|
||||||
|
// Use a new tab (keeps the app state intact)
|
||||||
|
try {
|
||||||
|
const w = window.open(vodLink, '_blank', 'noopener,noreferrer');
|
||||||
|
if (!w) window.location.href = vodLink;
|
||||||
|
} catch (_) {
|
||||||
|
window.location.href = vodLink;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movieId && hasPlayableSource) {
|
||||||
|
window.location.href = `/player.html?id=${encodeURIComponent(movieId)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isClickable) {
|
||||||
|
item.addEventListener('click', openIfPossible);
|
||||||
|
item.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openIfPossible(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'flex-grow-1';
|
||||||
|
|
||||||
|
const topRow = document.createElement('div');
|
||||||
|
topRow.className = 'd-flex align-items-start justify-content-between gap-2';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'stats-movie-title';
|
||||||
|
titleEl.textContent = title;
|
||||||
|
|
||||||
|
const right = document.createElement('div');
|
||||||
|
right.className = 'text-nowrap';
|
||||||
|
|
||||||
|
if (imdbId) {
|
||||||
|
const imdbLink = document.createElement('a');
|
||||||
|
imdbLink.className = 'btn btn-outline-dark btn-sm stats-movie-imdb';
|
||||||
|
imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`;
|
||||||
|
imdbLink.target = '_blank';
|
||||||
|
imdbLink.rel = 'noopener noreferrer';
|
||||||
|
imdbLink.setAttribute('aria-label', 'Voir sur IMDb');
|
||||||
|
|
||||||
|
const imdbText = document.createElement('span');
|
||||||
|
imdbText.className = 'small';
|
||||||
|
imdbText.textContent = 'IMDb';
|
||||||
|
|
||||||
|
const imdbIcon = document.createElement('img');
|
||||||
|
imdbIcon.src = '/img/imdb.png';
|
||||||
|
imdbIcon.className = 'imdb-icon';
|
||||||
|
imdbIcon.alt = 'IMDb';
|
||||||
|
|
||||||
|
imdbLink.appendChild(imdbText);
|
||||||
|
imdbLink.appendChild(imdbIcon);
|
||||||
|
right.appendChild(imdbLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
topRow.appendChild(titleEl);
|
||||||
|
topRow.appendChild(right);
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'stats-movie-meta';
|
||||||
|
|
||||||
|
if (rating !== null && rating > 0) {
|
||||||
|
const r = document.createElement('span');
|
||||||
|
r.className = 'text-muted';
|
||||||
|
r.textContent = `⭐ ${rating.toFixed(1)}`;
|
||||||
|
meta.appendChild(r);
|
||||||
|
} else if (ratingRaw) {
|
||||||
|
const r = document.createElement('span');
|
||||||
|
r.className = 'text-muted';
|
||||||
|
r.textContent = `⭐ ${ratingRaw}`;
|
||||||
|
meta.appendChild(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchedLabel) {
|
||||||
|
const d = document.createElement('span');
|
||||||
|
d.className = 'text-muted';
|
||||||
|
d.textContent = `Vu le ${watchedLabel}`;
|
||||||
|
meta.appendChild(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
main.appendChild(topRow);
|
||||||
|
if (meta.childNodes.length) main.appendChild(meta);
|
||||||
|
|
||||||
|
// Optional: keep long strings (like category) out of the main layout.
|
||||||
|
if (category) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'stats-movie-details';
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = 'Détails';
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'stats-movie-details-body text-muted small';
|
||||||
|
body.textContent = category;
|
||||||
|
details.appendChild(body);
|
||||||
|
|
||||||
|
main.appendChild(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(poster);
|
||||||
|
item.appendChild(main);
|
||||||
|
movieGridEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoviesModalForUser(userStat) {
|
||||||
|
currentModalUserName = safeStr(userStat?.name).trim();
|
||||||
|
currentModalMovies = Array.isArray(userStat?.watchedMovies) ? userStat.watchedMovies : [];
|
||||||
|
|
||||||
|
if (moviesModalTitleEl) {
|
||||||
|
const count = currentModalMovies.length;
|
||||||
|
moviesModalTitleEl.textContent = currentModalUserName
|
||||||
|
? `Films regardés — ${currentModalUserName} (${count})`
|
||||||
|
: `Films regardés (${count})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMoviesModal();
|
||||||
|
|
||||||
|
if (moviesModalEl) {
|
||||||
|
const modal = new bootstrap.Modal(moviesModalEl);
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moviesModalEl) {
|
||||||
|
moviesModalEl.addEventListener('shown.bs.modal', () => {
|
||||||
|
// no controls to focus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Show loading states immediately (avoid "blank" panels)
|
// Show loading states immediately (avoid "blank" panels)
|
||||||
const table = document.querySelector('.user-table');
|
const table = document.querySelector('.user-table');
|
||||||
const statsError = document.getElementById('stats-error');
|
const statsError = document.getElementById('stats-error');
|
||||||
@@ -366,8 +615,14 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const activeYear = await fetchActiveYear();
|
const activeYear = await fetchActiveYear();
|
||||||
if (activeYear) {
|
if (activeYear) {
|
||||||
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
|
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
|
||||||
|
// Keep the main heading clean; show the year in the "Classement" tab instead.
|
||||||
const h2 = document.querySelector('h2');
|
const h2 = document.querySelector('h2');
|
||||||
if (h2) h2.textContent = `Statistiques des utilisateurs (${activeYear})`;
|
if (h2) h2.textContent = 'Statistiques des utilisateurs';
|
||||||
|
const badge = document.getElementById('stats-active-year-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = String(activeYear);
|
||||||
|
badge.classList.remove('d-none');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
|
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
|
||||||
@@ -375,7 +630,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
const [statsRes, winners, completions] = await Promise.all([
|
const [statsRes, winners, completions] = await Promise.all([
|
||||||
fetch(statsUrl, {
|
fetch(statsUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
cache: 'no-cache',
|
||||||
}),
|
}),
|
||||||
fetchWinners(),
|
fetchWinners(),
|
||||||
fetchCompletions(),
|
fetchCompletions(),
|
||||||
@@ -402,13 +658,26 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
|
|
||||||
stats.forEach(userStat => {
|
stats.forEach(userStat => {
|
||||||
const userRow = document.createElement('tr');
|
const userRow = document.createElement('tr');
|
||||||
userRow.innerHTML = `
|
const nameTd = document.createElement('td');
|
||||||
<td>
|
const link = document.createElement('a');
|
||||||
<a href="#" class="user-link" data-movies='${JSON.stringify(userStat.watchedMovies)}'>${userStat.name}</a>
|
link.href = '#';
|
||||||
</td>
|
link.className = 'user-link';
|
||||||
<td>${userStat.watchedCount}</td>
|
link.textContent = userStat.name;
|
||||||
<td>${userStat.watchedRatio}</td>
|
link.addEventListener('click', function (event) {
|
||||||
`;
|
event.preventDefault();
|
||||||
|
openMoviesModalForUser(userStat);
|
||||||
|
});
|
||||||
|
nameTd.appendChild(link);
|
||||||
|
|
||||||
|
const countTd = document.createElement('td');
|
||||||
|
countTd.textContent = String(userStat.watchedCount ?? '');
|
||||||
|
|
||||||
|
const ratioTd = document.createElement('td');
|
||||||
|
ratioTd.textContent = String(userStat.watchedRatio ?? '');
|
||||||
|
|
||||||
|
userRow.appendChild(nameTd);
|
||||||
|
userRow.appendChild(countTd);
|
||||||
|
userRow.appendChild(ratioTd);
|
||||||
userTableBody.appendChild(userRow);
|
userTableBody.appendChild(userRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -417,32 +686,6 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
if (statsError) statsError.classList.add('d-none');
|
if (statsError) statsError.classList.add('d-none');
|
||||||
pageLoader.setProgress(92);
|
pageLoader.setProgress(92);
|
||||||
|
|
||||||
// Add event listener for user links
|
|
||||||
document.querySelectorAll('.user-link').forEach(link => {
|
|
||||||
link.addEventListener('click', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const datasetMovies = this.dataset.movies;
|
|
||||||
console.log(datasetMovies);
|
|
||||||
const movies = JSON.parse(datasetMovies);
|
|
||||||
|
|
||||||
// Clear the current movie list in the modal
|
|
||||||
const movieList = document.getElementById('movie-list');
|
|
||||||
movieList.innerHTML = '';
|
|
||||||
|
|
||||||
// Populate the modal with the movies the user has watched
|
|
||||||
movies.forEach(movie => {
|
|
||||||
const movieItem = document.createElement('li');
|
|
||||||
movieItem.classList.add('list-group-item');
|
|
||||||
movieItem.textContent = movie.title;
|
|
||||||
movieList.appendChild(movieItem);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('moviesModal'));
|
|
||||||
modal.show();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLoader.done();
|
pageLoader.done();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user statistics:', error);
|
console.error('Error loading user statistics:', error);
|
||||||
|
|||||||
@@ -84,6 +84,7 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
|
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
|
||||||
Classement
|
Classement
|
||||||
|
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Année active"></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
@@ -141,20 +142,18 @@
|
|||||||
<!-- 100% completers tab -->
|
<!-- 100% completers tab -->
|
||||||
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
|
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
|
||||||
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
|
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
|
||||||
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
|
|
||||||
<div class="table-responsive mb-4">
|
<div class="table-responsive mb-4">
|
||||||
<table class="table table-sm table-striped align-middle mb-0">
|
<table class="table table-sm table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" style="width: 110px;">Année</th>
|
<th scope="col" style="width: 110px;">Année</th>
|
||||||
<th scope="col" style="width: 140px;">Finisseur(s)</th>
|
|
||||||
<th scope="col" style="width: 140px;">Total films</th>
|
<th scope="col" style="width: 140px;">Total films</th>
|
||||||
<th scope="col">Noms</th>
|
<th scope="col">Noms</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="completers-table-body">
|
<tbody id="completers-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -165,16 +164,19 @@
|
|||||||
|
|
||||||
<!-- Modal for displaying movies watched -->
|
<!-- Modal for displaying movies watched -->
|
||||||
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
|
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5>
|
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<ul id="movie-list" class="list-group">
|
<div class="d-flex flex-column gap-3">
|
||||||
<!-- Movie titles will be inserted here -->
|
<div id="movies-summary" class="text-muted small"></div>
|
||||||
</ul>
|
|
||||||
|
<div id="movies-empty" class="text-muted d-none">Aucun film à afficher.</div>
|
||||||
|
<div id="movie-grid" class="stats-movies-grid" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ async function listBackupFiles() {
|
|||||||
router.get("/db/backups", async (req, res) => {
|
router.get("/db/backups", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
// Admin + auth-scoped data: never allow caching.
|
||||||
|
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.setHeader("Vary", "Authorization");
|
||||||
const backups = await listBackupFiles();
|
const backups = await listBackupFiles();
|
||||||
return res.status(200).json({ backups });
|
return res.status(200).json({ backups });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -227,6 +230,8 @@ router.post("/db/backup", async (req, res) => {
|
|||||||
router.get("/db/backups/:name", async (req, res) => {
|
router.get("/db/backups/:name", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.setHeader("Vary", "Authorization");
|
||||||
const safe = safeBackupFileName(req.params?.name);
|
const safe = safeBackupFileName(req.params?.name);
|
||||||
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
|
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
|
||||||
|
|
||||||
|
|||||||
+3
-21
@@ -291,6 +291,9 @@ router.get("/watchedMovies", async (req, res) => {
|
|||||||
const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
|
const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
|
||||||
if (!user) return res.status(404).json({ message: "User not found" });
|
if (!user) return res.status(404).json({ message: "User not found" });
|
||||||
|
|
||||||
|
// User-specific: never allow shared/proxy caching.
|
||||||
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.set("Vary", "Authorization");
|
||||||
res.status(200).json(user.watchedMovies); // Return the list of watched movies
|
res.status(200).json(user.watchedMovies); // Return the list of watched movies
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -361,16 +364,6 @@ router.post("/add", async (req, res) => {
|
|||||||
if (normalizedMode === null) {
|
if (normalizedMode === null) {
|
||||||
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!hasAnySourceIncludingFile({
|
|
||||||
vod_link: normalizedVod || "",
|
|
||||||
video_src: normalizedVideo || "",
|
|
||||||
embed_src: normalizedEmbed || "",
|
|
||||||
video_file: normalizedFile || "",
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch movie details from OMDb API
|
// Fetch movie details from OMDb API
|
||||||
let movieDetails;
|
let movieDetails;
|
||||||
@@ -584,17 +577,6 @@ router.put("/:id", async (req, res) => {
|
|||||||
if (poster !== undefined) movie.poster = poster;
|
if (poster !== undefined) movie.poster = poster;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!hasAnySourceIncludingFile({
|
|
||||||
vod_link: movie.vod_link || "",
|
|
||||||
video_src: movie.video_src || "",
|
|
||||||
embed_src: movie.embed_src || "",
|
|
||||||
video_file: movie.video_file || "",
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src / video_file)." });
|
|
||||||
}
|
|
||||||
|
|
||||||
await movie.save();
|
await movie.save();
|
||||||
cacheClear();
|
cacheClear();
|
||||||
return res.status(200).json(movie);
|
return res.status(200).json(movie);
|
||||||
|
|||||||
+43
-13
@@ -122,14 +122,20 @@ async function getOrInitActiveYear() {
|
|||||||
// Get user stats with watched movie titles and details from the movies collection
|
// Get user stats with watched movie titles and details from the movies collection
|
||||||
router.get("/stats", verifyToken, async (req, res) => {
|
router.get("/stats", verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Fetch all users
|
// Auth-scoped response: never allow caching.
|
||||||
const users = await User.find();
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.set("Vary", "Authorization");
|
||||||
|
|
||||||
|
// Fetch all users (only needed fields)
|
||||||
|
const users = await User.find().select("name watchedMovies").lean();
|
||||||
|
|
||||||
// If a year is explicitly provided, use it; otherwise default to the global active year.
|
// If a year is explicitly provided, use it; otherwise default to the global active year.
|
||||||
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||||
|
|
||||||
// Fetch movies (optionally filtered by year)
|
// Fetch movies (optionally filtered by year)
|
||||||
const allMovies = await Movie.find({ year });
|
const allMovies = await Movie.find({ year })
|
||||||
|
.select("imdb_id title poster rating category year vod_link player_mode video_src embed_src video_file")
|
||||||
|
.lean();
|
||||||
const totalMoviesCount = allMovies.length;
|
const totalMoviesCount = allMovies.length;
|
||||||
|
|
||||||
if (totalMoviesCount === 0) {
|
if (totalMoviesCount === 0) {
|
||||||
@@ -140,19 +146,35 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
const userStats = users.map((user) => {
|
const userStats = users.map((user) => {
|
||||||
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
|
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
|
||||||
|
|
||||||
// Get watched movies with details from the database
|
// Join watched movies with movie details for the selected year (keep watchedDate)
|
||||||
const watchedMovies = user.watchedMovies
|
const watchedRaw = Array.isArray(user?.watchedMovies) ? user.watchedMovies : [];
|
||||||
.map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
|
const watchedMoviesDetails = watchedRaw
|
||||||
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
|
.map((wm) => {
|
||||||
|
const imdbId = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
|
||||||
|
if (!imdbId) return null;
|
||||||
|
const movie = movieByImdbId.get(imdbId);
|
||||||
|
if (!movie) return null;
|
||||||
|
return {
|
||||||
|
movieId: movie?._id ? String(movie._id) : null,
|
||||||
|
imdb_id: movie.imdb_id,
|
||||||
|
title: movie.title || "",
|
||||||
|
poster: movie.poster || "",
|
||||||
|
rating: movie.rating || "",
|
||||||
|
category: movie.category || "",
|
||||||
|
year: Number.isInteger(movie.year) ? movie.year : year,
|
||||||
|
vod_link: movie.vod_link || "",
|
||||||
|
player_mode: movie.player_mode || "auto",
|
||||||
|
video_src: movie.video_src || "",
|
||||||
|
embed_src: movie.embed_src || "",
|
||||||
|
video_file: movie.video_file || "",
|
||||||
|
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate).toISOString() : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const watchedCount = watchedMovies.length;
|
const watchedCount = watchedMoviesDetails.length;
|
||||||
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
|
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
|
||||||
|
|
||||||
const watchedMoviesDetails = watchedMovies.map((movie) => ({
|
|
||||||
imdb_id: movie.imdb_id,
|
|
||||||
title: movie.title
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
year,
|
year,
|
||||||
@@ -174,6 +196,10 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
|
||||||
router.get("/completions", verifyToken, async (req, res) => {
|
router.get("/completions", verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Auth-scoped response: never allow caching.
|
||||||
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.set("Vary", "Authorization");
|
||||||
|
|
||||||
const yearsRaw = await Movie.distinct("year");
|
const yearsRaw = await Movie.distinct("year");
|
||||||
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
|
||||||
.map((y) => Number(y))
|
.map((y) => Number(y))
|
||||||
@@ -376,6 +402,10 @@ router.get("/admin/list", verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Admin + auth-scoped data: never allow caching.
|
||||||
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
||||||
|
res.set("Vary", "Authorization");
|
||||||
|
|
||||||
const users = await User.find()
|
const users = await User.find()
|
||||||
.select("name email role mustChangePassword")
|
.select("name email role mustChangePassword")
|
||||||
.sort({ name: 1, email: 1 });
|
.sort({ name: 1, email: 1 });
|
||||||
|
|||||||
Reference in New Issue
Block a user