Merge pull request #61 from quoije/2026

v2.3 - redesign admin panel, redesign stats, fix cache and more
This commit was merged in pull request #61.
This commit is contained in:
Mathieu Moran
2025-12-22 18:17:47 -05:00
committed by GitHub
10 changed files with 1236 additions and 469 deletions
+503 -185
View File
@@ -101,52 +101,61 @@
<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>
<ul class="nav nav-pills admin-subtabs mt-3" id="adminSettingsSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<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">
Année active Année active
</button> </button>
</h2> </li>
<div id="adminSettingsCollapseYear" class="accordion-collapse collapse show" aria-labelledby="adminSettingsHeadingYear" data-bs-parent="#adminSettingsAccordion"> <li class="nav-item" role="presentation">
<div class="accordion-body"> <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="text-muted small mb-2"> Lecteur
</button>
</li>
<li class="nav-item" role="presentation">
<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">
Date Oscars
</button>
</li>
</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). Pilote tout le site (films, checklist, statistiques).
</div> </div>
<div class="d-flex flex-wrap gap-2 align-items-end justify-content-between"> <label for="active_oscar_year" class="form-label">Année active</label>
<div class="flex-grow-1" style="min-width: 220px;"> <div class="admin-settings-narrow">
<label for="active_oscar_year" class="form-label mb-1">Année active</label> <div class="input-group">
<select class="form-select" id="active_oscar_year"></select> <select class="form-select" id="active_oscar_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer</button> <button type="button" id="save-active-year" class="btn btn-warning">Appliquer</button>
</div> </div>
</div> </div>
</div> <div class="form-text">Basé sur les années présentes dans les films.</div>
</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">
Lecteur (player)
</button>
</h2>
<div id="adminSettingsCollapsePlayer" class="accordion-collapse collapse" aria-labelledby="adminSettingsHeadingPlayer" data-bs-parent="#adminSettingsAccordion">
<div class="accordion-body">
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between"> <div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="player_ui_show_status"> <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> <label class="form-check-label" for="player_ui_show_status">Afficher “Source” + “Progress” (admins)</label>
</div> </div>
<div class="d-flex gap-2"> <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_reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="player_ui_save" class="btn btn-warning btn-sm">Enregistrer</button> <button type="button" id="player_ui_save" class="btn btn-warning">Enregistrer</button>
</div> </div>
</div> </div>
<div class="form-text mt-2"> <div class="form-text mt-2">
@@ -154,43 +163,34 @@
</div> </div>
</div> </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)
</button>
</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 &amp; Checklist). Défaut: <code>15 mars</code>. Configure la date de cérémonie pour le compte à rebours (Films &amp; Checklist). Défaut: <code>15 mars</code>.
</div> </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>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<label for="oscar_date_value" class="form-label mb-1">Date</label> <label for="oscar_date_value" class="form-label">Date</label>
<input type="date" class="form-control" id="oscar_date_value"> <input type="date" class="form-control" id="oscar_date_value">
<div class="form-text">Format: YYYY-MM-DD.</div> <div class="form-text">Format: YYYY-MM-DD.</div>
</div> </div>
<div class="col-12 col-md-4 d-flex align-items-end justify-content-end gap-2"> <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 btn-sm">Effacer</button> <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 btn-sm">Enregistrer</button> <button type="button" id="save-oscar-date" class="btn btn-warning w-100">Enregistrer</button>
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<div class="text-muted small mb-1">Dates configurées</div> <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 id="oscar-date-current" class="border rounded p-2 bg-white">—</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -199,59 +199,125 @@
</div> </div>
<div class="tab-pane fade" id="admin-version" role="tabpanel" aria-labelledby="admin-version-tab"> <div class="tab-pane fade" id="admin-version" role="tabpanel" aria-labelledby="admin-version-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Version active (footer)</h4> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<div class="text-muted mb-3"> <div>
Remplace complètement le checker GitHub. La version affichée dans le footer vient de la DB et peut être changée ici. <h4 class="mb-1">Version</h4>
<div class="text-muted">Contrôle la version affichée dans le footer (stockée en DB).</div>
</div>
</div> </div>
<div id="app_version_alert" class="alert d-none" role="alert"></div> <div id="app_version_alert" class="alert d-none mt-3 mb-0" role="alert"></div>
<div class="mb-3"> <ul class="nav nav-pills admin-subtabs mt-3" id="adminVersionSubTabs" role="tablist">
<label for="app_version_active_select" class="form-label">Choisir la version active</label> <li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-version-subtab-active" data-bs-toggle="tab" data-bs-target="#admin-version-pane-active" type="button" role="tab" aria-controls="admin-version-pane-active" aria-selected="true">
Active
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-version-subtab-history" data-bs-toggle="tab" data-bs-target="#admin-version-pane-history" type="button" role="tab" aria-controls="admin-version-pane-history" aria-selected="false">
Historique
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-version-subtab-create" data-bs-toggle="tab" data-bs-target="#admin-version-pane-create" type="button" role="tab" aria-controls="admin-version-pane-create" aria-selected="false">
Nouvelle version
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Active -->
<div class="tab-pane fade show active" id="admin-version-pane-active" role="tabpanel" aria-labelledby="admin-version-subtab-active" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1">Version active</div>
<div class="text-muted small mb-3">
Met à jour ce que les utilisateurs voient dans le footer.
</div>
<label for="app_version_active_select" class="form-label">Choisir une version</label>
<div class="row g-2 align-items-end">
<div class="col-12 col-lg-8">
<select id="app_version_active_select" class="form-select"></select> <select id="app_version_active_select" class="form-select"></select>
<div class="form-text">Cette sélection impacte ce que les utilisateurs voient dans le footer.</div> <div class="form-text">La liste vient de l’historique stocké en DB.</div>
</div>
<div class="col-12 col-lg-4 d-flex gap-2">
<button type="button" id="app_version_reload" class="btn btn-outline-secondary w-100">Recharger</button>
<button type="button" id="app_version_set_active" class="btn btn-warning w-100">Activer</button>
</div> </div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="app_version_reload" class="btn btn-outline-primary">Recharger</button>
<button type="button" id="app_version_set_active" class="btn btn-warning">Définir active</button>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<div class="text-muted small mb-1">Aperçu (format footer)</div> <div class="text-muted small mb-1">Aperçu (footer)</div>
<div id="app_version_preview" class="border rounded p-2 bg-light">—</div> <div id="app_version_preview" class="border rounded p-2 bg-white app-version-preview">—</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-12 col-xxl-6"> <div class="col-12 col-xxl-5">
<div class="card p-3"> <div class="border rounded p-3 bg-white">
<h4 class="mb-1">Créer une nouvelle version</h4> <div class="fw-semibold mb-2">Bon à savoir</div>
<div class="text-muted mb-3"> <ul class="mb-0 text-muted">
Ajoute une entrée dans l’historique, puis tu peux la rendre active. <li>“Recharger” relit l’historique depuis l’API.</li>
<li>“Activer” change la version active en DB.</li>
<li>Le footer lit ensuite la version via <code>/api/version</code>.</li>
</ul>
</div>
</div>
</div>
</div>
<!-- History -->
<div class="tab-pane fade" id="admin-version-pane-history" role="tabpanel" aria-labelledby="admin-version-subtab-history" tabindex="0">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">Historique</div>
<div class="text-muted small">Supprime les entrées obsolètes si besoin.</div>
</div>
</div>
<div id="app_version_list" class="border rounded bg-white app-version-list">—</div>
</div>
<!-- Create -->
<div class="tab-pane fade" id="admin-version-pane-create" role="tabpanel" aria-labelledby="admin-version-subtab-create" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1">Créer une nouvelle version</div>
<div class="text-muted small mb-3">
Ajoute une entrée dans l’historique (tu peux l’activer immédiatement).
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-12 col-md-4">
<label for="app_version_new_version" class="form-label">Version</label> <label for="app_version_new_version" class="form-label">Version</label>
<input id="app_version_new_version" type="text" class="form-control" placeholder="ex: 1.0.1" maxlength="80"> <input id="app_version_new_version" type="text" class="form-control" placeholder="ex: 1.0.1" maxlength="80">
<div class="form-text">Ex: <code>1.2.3</code></div>
</div> </div>
<div class="col-md-8"> <div class="col-12 col-md-8">
<label for="app_version_new_message" class="form-label">Message (optionnel)</label> <label for="app_version_new_message" class="form-label">Message (optionnel)</label>
<input id="app_version_new_message" type="text" class="form-control" placeholder="ex: Hotfix login" maxlength="500"> <input id="app_version_new_message" type="text" class="form-control" placeholder="ex: Hotfix login" maxlength="500">
<div class="form-text">Court &amp; lisible (affiché dans le footer).</div>
</div> </div>
<div class="col-12 d-flex flex-wrap justify-content-end gap-2"> <div class="col-12 d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="app_version_create" class="btn btn-primary">Créer</button> <button type="button" id="app_version_create" class="btn btn-primary">Créer</button>
<button type="button" id="app_version_create_and_activate" class="btn btn-warning">Créer &amp; activer</button> <button type="button" id="app_version_create_and_activate" class="btn btn-warning">Créer &amp; activer</button>
</div> </div>
</div> </div>
</div>
</div>
<div class="mt-3"> <div class="col-12 col-xxl-5">
<div class="text-muted small mb-2">Historique</div> <div class="border rounded p-3 bg-white">
<div id="app_version_list" class="border rounded bg-white app-version-list">—</div> <div class="fw-semibold mb-2">Astuce</div>
<div class="text-muted">
Après création, va dans l’onglet “Active” pour vérifier l’aperçu du footer.
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -259,13 +325,37 @@
</div> </div>
<div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab"> <div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Contenu “Modal 100%”</h4> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<p class="text-muted mb-3"> <div>
Contenu affiché quand un utilisateur a vu 100% des films dans la checklist. <h4 class="mb-1">Modal 100%</h4>
</p> <div class="text-muted">Contenu affiché quand un utilisateur a vu 100% des films.</div>
</div>
</div>
<ul class="nav nav-pills admin-subtabs mt-3" id="adminCompletionModalSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-modal-100-subtab-edit" data-bs-toggle="tab" data-bs-target="#admin-modal-100-pane-edit" type="button" role="tab" aria-controls="admin-modal-100-pane-edit" aria-selected="true">
Éditer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-modal-100-subtab-help" data-bs-toggle="tab" data-bs-target="#admin-modal-100-pane-help" type="button" role="tab" aria-controls="admin-modal-100-pane-help" aria-selected="false">
Aide
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Edit + Live preview side-by-side -->
<div class="tab-pane fade show active" id="admin-modal-100-pane-edit" role="tabpanel" aria-labelledby="admin-modal-100-subtab-edit" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light h-100">
<div class="fw-semibold mb-1">Contenu</div>
<div class="text-muted small mb-3">
Modifie le contenu puis enregistre. L’aperçu est live (non sauvegardé tant que tu n’enregistres pas).
</div>
<div class="mb-3"> <div class="mb-3">
<label for="completion_modal_title" class="form-label">Titre</label> <label for="completion_modal_title" class="form-label">Titre</label>
@@ -299,15 +389,16 @@
</div> </div>
</div> </div>
<div class="col-12 col-xxl-6"> <div class="col-12 col-xxl-5">
<div class="card p-3"> <div class="border rounded p-3 bg-white h-100">
<h4 class="mb-1">Aperçu</h4> <div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div class="text-muted mb-3">Aperçu live (non sauvegardé tant que tu n’enregistres pas).</div> <div class="fw-semibold">Aperçu</div>
</div>
<div class="text-muted small mb-3">Aperçu live (non sauvegardé).</div>
<div class="border rounded p-3 bg-white"> <div class="border rounded p-3 bg-light">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2"> <div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div class="fw-semibold" id="completion_modal_preview_title">—</div> <div class="fw-semibold" id="completion_modal_preview_title">—</div>
<span class="badge bg-secondary">Preview</span>
</div> </div>
<div id="completion_modal_preview_custom" class="text-start d-none"></div> <div id="completion_modal_preview_custom" class="text-start d-none"></div>
<div id="completion_modal_preview_text" class="text-start" style="white-space: pre-wrap;"></div> <div id="completion_modal_preview_text" class="text-start" style="white-space: pre-wrap;"></div>
@@ -324,6 +415,33 @@
</div> </div>
</div> </div>
<!-- Help -->
<div class="tab-pane fade" id="admin-modal-100-pane-help" role="tabpanel" aria-labelledby="admin-modal-100-subtab-help" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Règles</div>
<ul class="mb-0 text-muted">
<li><strong>Texte</strong>: affiché tel quel (safe), retours à la ligne conservés.</li>
<li><strong>HTML</strong>: le serveur retire les <code>&lt;script&gt;</code> et les attributs <code>on*</code>.</li>
<li><strong>Vidéo</strong>: accepte un chemin relatif (ex: <code>video/reward.mp4</code>) ou une URL complète.</li>
</ul>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-2">Astuce</div>
<div class="text-muted">
Si tu utilises du HTML, préfère des tags simples (<code>&lt;p&gt;</code>, <code>&lt;a&gt;</code>, <code>&lt;strong&gt;</code>) pour éviter des surprises d’affichage.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab"> <div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab">
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
@@ -362,92 +480,67 @@
</div> </div>
<div class="tab-pane fade" id="admin-movies" role="tabpanel" aria-labelledby="admin-movies-tab"> <div class="tab-pane fade" id="admin-movies" role="tabpanel" aria-labelledby="admin-movies-tab">
<div class="row g-3">
<div class="col-12 col-xxl-4">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Ajouter un film</h4> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<p class="text-muted mb-3"> <div>
Ajoute un film via l’API (<code>/api/movies/add</code>). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’<code>imdb_id</code>. <h4 class="mb-1">Films</h4>
</p> <div class="text-muted">Gestion et ajout des films (par année).</div>
<form id="add-movie-form">
<div class="mb-3">
<label for="oscar_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="oscar_year" placeholder="2026" min="1900" max="3000" required>
<div class="form-text">Permet de séparer les films et les stats par année.</div>
</div>
<div class="mb-3">
<label for="imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
<input type="text" class="form-control" id="imdb_id" placeholder="tt..." required>
</div>
<div class="mb-3">
<label for="category" class="form-label">Catégorie</label>
<input type="text" class="form-control" id="category" placeholder="Best Picture, etc." required>
</div>
<div class="mb-3">
<label class="form-label">Lecteur (source)</label>
<div class="row g-2">
<div class="col-12 col-lg-4">
<label for="player_mode" class="form-label">Mode</label>
<select id="player_mode" class="form-select">
<option value="auto" selected>Auto</option>
<option value="video">Video</option>
<option value="embed">Embed</option>
</select>
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
</div>
<div class="col-12 col-lg-8">
<label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
<input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/&lt;movieId&gt;</code>.</div>
</div>
<div class="col-12">
<label for="video_file" class="form-label">Server video file (optional)</label>
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">
Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>). Supports seeking (Range).
</div>
</div>
<div class="col-12">
<label for="embed_src" class="form-label">Embed URL or iframe code</label>
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://www.youtube.com/watch?v=... OR <iframe src='...'></iframe>"></textarea>
</div>
<div class="col-12">
<label for="vod_link" class="form-label">Legacy VOD link (optional)</label>
<input type="text" class="form-control" id="vod_link" placeholder="https://...">
<div class="form-text">Backward-compat. Prefer video_src / embed_src above.</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Ajouter</button>
<button type="button" id="reset-form" class="btn btn-outline-secondary">Reset</button>
</div>
</form>
</div> </div>
</div> </div>
<div class="col-12 col-xxl-8"> <ul class="nav nav-pills admin-subtabs mt-3" id="adminMoviesSubTabs" role="tablist">
<div class="card p-3"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-movies-subtab-manage" data-bs-toggle="tab" data-bs-target="#admin-movies-pane-manage" type="button" role="tab" aria-controls="admin-movies-pane-manage" aria-selected="true">
Gérer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-movies-subtab-add" data-bs-toggle="tab" data-bs-target="#admin-movies-pane-add" type="button" role="tab" aria-controls="admin-movies-pane-add" aria-selected="false">
Ajouter
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-movies-subtab-help" data-bs-toggle="tab" data-bs-target="#admin-movies-pane-help" type="button" role="tab" aria-controls="admin-movies-pane-help" aria-selected="false">
Aide
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Manage -->
<div class="tab-pane fade show active" id="admin-movies-pane-manage" role="tabpanel" aria-labelledby="admin-movies-subtab-manage" tabindex="0">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2"> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div> <div>
<h4 class="mb-1">Gérer les films</h4> <h5 class="mb-1">Gérer les films</h5>
<div class="text-muted">Sélectionne une année, puis coche les films à supprimer.</div> <div class="text-muted">Filtre, sélection et suppression en lot.</div>
</div>
<div class="text-muted small">
<span id="admin-movies-table-count">—</span>
</div> </div>
</div> </div>
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-md-6 col-xl-4"> <div class="col-12 col-md-4 col-xl-3">
<label for="manage_year" class="form-label">Oscar year</label> <label for="manage_year" class="form-label">Oscar year</label>
<select id="manage_year" class="form-select"> <select id="manage_year" class="form-select">
<option value="">Toutes</option> <option value="">Toutes</option>
</select> </select>
</div> </div>
<div class="col-md-6 col-xl-4 d-flex gap-2">
<div class="col-12 col-md-8 col-xl-5">
<label for="admin_movies_search" class="form-label">Recherche</label>
<div class="input-group">
<input type="text" class="form-control" id="admin_movies_search" placeholder="Titre, IMDB ID, catégorie…" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="admin_movies_clear_search">Effacer</button>
</div>
</div>
<div class="col-12 col-xl-4 d-flex gap-2">
<button type="button" id="refresh-movies" class="btn btn-outline-primary w-100">Rafraîchir</button> <button type="button" id="refresh-movies" class="btn btn-outline-primary w-100">Rafraîchir</button>
<button type="button" id="delete-selected" class="btn btn-danger w-100" disabled>Supprimer</button> <button type="button" id="delete-selected" class="btn btn-danger w-100" disabled>Supprimer</button>
</div> </div>
<div class="col-12 col-xl-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="select-all-movies"> <input class="form-check-input" type="checkbox" id="select-all-movies">
@@ -459,7 +552,7 @@
</div> </div>
<div class="table-responsive mt-2"> <div class="table-responsive mt-2">
<table class="table table-sm align-middle mb-0"> <table class="table table-sm align-middle mb-0 admin-movies-table">
<thead> <thead>
<tr> <tr>
<th style="width: 1%;">Sel.</th> <th style="width: 1%;">Sel.</th>
@@ -477,25 +570,153 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Add -->
<div class="tab-pane fade" id="admin-movies-pane-add" role="tabpanel" aria-labelledby="admin-movies-subtab-add" tabindex="0">
<h5 class="mb-2">Ajouter un film</h5>
<div class="text-muted mb-3">
Le serveur récupère automatiquement titre/plot/poster via OMDb avec l’<code>imdb_id</code>.
</div>
<form id="add-movie-form">
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="oscar_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="oscar_year" placeholder="2026" min="1900" max="3000" required>
<div class="form-text">Sépare films &amp; stats par année.</div>
</div>
<div class="col-12 col-md-4">
<label for="imdb_id" class="form-label">IMDB ID</label>
<input type="text" class="form-control" id="imdb_id" placeholder="tt1234567" required>
</div>
<div class="col-12 col-md-4">
<label for="category" class="form-label">Catégorie</label>
<input type="text" class="form-control" id="category" placeholder="Best Picture, etc." required>
</div>
<div class="col-12">
<div class="accordion" id="adminAddMovieAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="adminAddMovieHeadingPlayer">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#adminAddMovieCollapsePlayer" aria-expanded="true" aria-controls="adminAddMovieCollapsePlayer">
Source vidéo / Player
</button>
</h2>
<div id="adminAddMovieCollapsePlayer" class="accordion-collapse collapse show" aria-labelledby="adminAddMovieHeadingPlayer" data-bs-parent="#adminAddMovieAccordion">
<div class="accordion-body">
<div class="row g-2">
<div class="col-12 col-lg-4">
<label for="player_mode" class="form-label">Mode</label>
<select id="player_mode" class="form-select">
<option value="auto" selected>Auto</option>
<option value="video">Video</option>
<option value="embed">Embed</option>
</select>
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
</div>
<div class="col-12 col-lg-8">
<label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
<input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
<div class="form-text">Si <code>video_file</code> est fourni et que ce champ est vide, la lecture utilisera <code>/api/video/&lt;movieId&gt;</code>.</div>
</div>
<div class="col-12">
<label for="video_file" class="form-label">Server video file (optionnel)</label>
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code> (défaut: <code>public/video</code>).</div>
</div>
<div class="col-12">
<label for="embed_src" class="form-label">Embed URL ou iframe</label>
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://... OR <iframe src='...'></iframe>"></textarea>
</div>
<div class="col-12">
<label for="vod_link" class="form-label">Legacy VOD link (optionnel)</label>
<input type="text" class="form-control" id="vod_link" placeholder="https://...">
<div class="form-text">Compat. Préfère <code>video_src</code> / <code>embed_src</code>.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="reset-form" class="btn btn-outline-secondary">Reset</button>
<button type="submit" class="btn btn-primary">Ajouter</button>
</div>
</div>
</form>
</div>
<!-- Help -->
<div class="tab-pane fade" id="admin-movies-pane-help" role="tabpanel" aria-labelledby="admin-movies-subtab-help" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-2">Notes rapides</div>
<ul class="mb-0">
<li><strong>Auto</strong>: utilise <code>video_src</code> → <code>embed_src</code> → <code>vod_link</code>.</li>
<li><strong>Server file</strong>: mets <code>video_file</code> (chemin relatif sous <code>public/video</code>).</li>
<li><strong>Édition</strong>: le bouton “Éditer” ouvre une modale avec des onglets Info / Player.</li>
</ul>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Astuce</div>
<div class="text-muted">
Dans “Gérer”, la recherche filtre la table localement (sans requête API). Le “Tout sélectionner” ne coche que les lignes visibles.
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane fade" id="admin-backup" role="tabpanel" aria-labelledby="admin-backup-tab"> <div class="tab-pane fade" id="admin-backup" role="tabpanel" aria-labelledby="admin-backup-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Backup</h4> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<p class="text-muted mb-3"> <div>
Crée une sauvegarde de la base MongoDB (format <code>.ndjson.gz</code>) et conserve-la sur le serveur. <h4 class="mb-1">Backup / Restore</h4>
</p> <div class="text-muted">Sauvegarde et restauration de la base MongoDB.</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" id="db-backup-create" class="btn btn-warning">Créer une sauvegarde</button>
<button type="button" id="db-backup-refresh" class="btn btn-outline-primary">Rafraîchir la liste</button>
</div> </div>
<div class="table-responsive mt-3"> <ul class="nav nav-pills admin-subtabs mt-3" id="adminBackupSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-backup-subtab-backup" data-bs-toggle="tab" data-bs-target="#admin-backup-pane-backup" type="button" role="tab" aria-controls="admin-backup-pane-backup" aria-selected="true">
Backup
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-subtab-restore" data-bs-toggle="tab" data-bs-target="#admin-backup-pane-restore" type="button" role="tab" aria-controls="admin-backup-pane-restore" aria-selected="false">
Restore
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-subtab-help" data-bs-toggle="tab" data-bs-target="#admin-backup-pane-help" type="button" role="tab" aria-controls="admin-backup-pane-help" aria-selected="false">
Aide
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Backup -->
<div class="tab-pane fade show active" id="admin-backup-pane-backup" role="tabpanel" aria-labelledby="admin-backup-subtab-backup" tabindex="0">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">Sauvegardes</div>
<div class="text-muted small">Format <code>.ndjson.gz</code>, stocké sur le serveur.</div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" id="db-backup-create" class="btn btn-warning">Créer une sauvegarde</button>
<button type="button" id="db-backup-refresh" class="btn btn-outline-secondary">Rafraîchir</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0"> <table class="table table-sm align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -511,16 +732,18 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Restore -->
<div class="tab-pane fade" id="admin-backup-pane-restore" role="tabpanel" aria-labelledby="admin-backup-subtab-restore" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold text-danger mb-1">Restaurer une sauvegarde</div>
<div class="text-muted small mb-3">
Restaure un fichier <code>.ndjson.gz</code>. L’option <strong>drop</strong> est recommandée.
</div> </div>
<div class="col-12 col-xxl-6"> <div class="alert alert-warning mb-3">
<div class="card p-3">
<h4 class="mb-1 text-danger">Restore</h4>
<p class="text-muted mb-3">
Restaure une sauvegarde (fichier <code>.ndjson.gz</code>). Option recommandée: <strong>drop</strong> pour supprimer les collections avant import.
</p>
<div class="alert alert-warning">
La restauration peut <strong>écraser</strong> les données. Fais un backup juste avant si besoin. La restauration peut <strong>écraser</strong> les données. Fais un backup juste avant si besoin.
</div> </div>
@@ -543,51 +766,146 @@
<div id="db-restore-result" class="small text-muted mt-3"></div> <div id="db-restore-result" class="small text-muted mt-3"></div>
</div> </div>
</div> </div>
<div class="col-12 col-xxl-5">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Conseil</div>
<div class="text-muted">
Utilise <strong>drop</strong> sauf si tu sais exactement ce que tu fais (sinon risque de doublons/erreurs).
</div>
</div>
</div>
</div>
</div>
<!-- Help -->
<div class="tab-pane fade" id="admin-backup-pane-help" role="tabpanel" aria-labelledby="admin-backup-subtab-help" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Fonctionnement</div>
<ul class="mb-0 text-muted">
<li><strong>Backup</strong>: crée un fichier <code>.ndjson.gz</code> (export MongoDB) et le garde sur le serveur.</li>
<li><strong>Télécharger</strong>: récupère le fichier localement (utile avant une manipulation risquée).</li>
<li><strong>Restore</strong>: importe le fichier. Avec <strong>drop</strong>, les collections restaurées sont d’abord supprimées.</li>
</ul>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-2">Check rapide</div>
<div class="text-muted">
Après restore, recharge la page et vérifie “Films”, “Checklist” et “Statistiques”.
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab"> <div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="card p-3"> <div class="card p-3">
<h4 class="mb-1">Définir les gagnants par année (égalité)</h4> <div class="d-flex flex-wrap align-items-end justify-content-between gap-2">
<div class="text-muted mb-3"> <div>
Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points). <h4 class="mb-1">Gagnants</h4>
Choisis un utilisateur + des points (optionnel), puis clique <strong>Ajouter</strong>. <div class="text-muted">Définis un ou plusieurs gagnants par année (égalité possible).</div>
</div>
</div>
<ul class="nav nav-pills admin-subtabs mt-3" id="adminWinnersSubTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-winners-subtab-set" data-bs-toggle="tab" data-bs-target="#admin-winners-pane-set" type="button" role="tab" aria-controls="admin-winners-pane-set" aria-selected="true">
Gérer
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-winners-subtab-help" data-bs-toggle="tab" data-bs-target="#admin-winners-pane-help" type="button" role="tab" aria-controls="admin-winners-pane-help" aria-selected="false">
Aide
</button>
</li>
</ul>
<div class="tab-content pt-3">
<!-- Set -->
<div class="tab-pane fade show active" id="admin-winners-pane-set" role="tabpanel" aria-labelledby="admin-winners-subtab-set" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-7">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-1">Ajouter un gagnant</div>
<div class="text-muted small mb-3">
Tu peux ajouter plusieurs gagnants pour une même année (égalité).
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-12 col-md-4">
<label for="winner_year" class="form-label">Année</label> <label for="winner_year" class="form-label">Année</label>
<select id="winner_year" class="form-select"></select> <select id="winner_year" class="form-select"></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>
<div class="col-md-5"> <div class="col-12 col-md-5">
<label for="winner_user" class="form-label">Utilisateur</label> <label for="winner_user" class="form-label">Utilisateur</label>
<select id="winner_user" class="form-select"> <select id="winner_user" class="form-select">
<option value="">—</option> <option value="">—</option>
</select> </select>
<div class="form-text">Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.</div> <div class="form-text">Pour retirer: utilise la liste ci-dessous.</div>
</div> </div>
<div class="col-md-3"> <div class="col-12 col-md-3">
<label for="winner_points" class="form-label">Points (optionnel)</label> <label for="winner_points" class="form-label">Points (optionnel)</label>
<input id="winner_points" type="number" class="form-control" placeholder="ex: 42" min="0" step="1"> <input id="winner_points" type="number" class="form-control" placeholder="ex: 42" min="0" step="1">
<div class="form-text">Laisse vide si non applicable.</div>
</div> </div>
</div> </div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3"> <div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button> <button type="button" id="winner_reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="winner_clear" class="btn btn-outline-danger">Tout supprimer</button> <button type="button" id="winner_clear" class="btn btn-outline-danger">Tout supprimer</button>
<button type="button" id="winner_save" class="btn btn-warning">Ajouter</button> <button type="button" id="winner_save" class="btn btn-warning">Ajouter</button>
</div> </div>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="border rounded p-3 bg-white h-100">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">Gagnants actuels</div>
<div class="text-muted small">Vue globale (toutes les années).</div>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Gagnants actuels</div>
<div id="winner_current" class="border rounded p-2 bg-light">—</div> <div id="winner_current" class="border rounded p-2 bg-light">—</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Help -->
<div class="tab-pane fade" id="admin-winners-pane-help" role="tabpanel" aria-labelledby="admin-winners-subtab-help" tabindex="0">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="border rounded p-3 bg-white">
<div class="fw-semibold mb-2">Comment ça marche</div>
<ul class="mb-0 text-muted">
<li>Une année peut avoir <strong>plusieurs gagnants</strong> (égalité).</li>
<li>Les points sont <strong>optionnels</strong> (affichage uniquement).</li>
<li>Pour retirer un gagnant, utilise le bouton “Retirer” dans la liste “Gagnants actuels”.</li>
</ul>
</div>
</div>
<div class="col-12 col-xxl-4">
<div class="border rounded p-3 bg-light">
<div class="fw-semibold mb-2">Astuce</div>
<div class="text-muted">
Si tu changes l’année active du site, vérifie aussi les gagnants de cette année.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+148
View File
@@ -166,6 +166,41 @@ footer {
border-bottom: 0; border-bottom: 0;
} }
/* Admin: compact "sub tabs" used inside panels */
.admin-subtabs {
gap: 0.5rem;
}
.admin-subtabs .nav-item {
margin: 0;
}
.admin-subtabs.nav-pills .nav-link {
border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
color: #111;
font-weight: 600;
padding: 0.45rem 0.8rem;
border-radius: 10px;
}
.admin-subtabs.nav-pills .nav-link:hover {
background: rgba(0, 0, 0, 0.03);
}
.admin-subtabs.nav-pills .nav-link.active {
background: #111;
border-color: #111;
color: #fff;
}
/* Admin → Version preview: keep it compact */
.app-version-preview {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.92rem;
}
/* Movies page helpers (Bootstrap-first, minimal custom) */ /* Movies page helpers (Bootstrap-first, minimal custom) */
.movie-poster { .movie-poster {
width: 100%; width: 100%;
@@ -184,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%;
}
}
+9 -6
View File
@@ -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) {
+57 -45
View File
@@ -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})`;
@@ -986,22 +987,10 @@ window.onload = async function () {
return winnersByYear.get(String(y)) || null; return winnersByYear.get(String(y)) || null;
} }
function renderWinnerCurrent() { function sortWinnersForDisplay(list) {
if (!winnerCurrentEl || !winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) {
winnerCurrentEl.textContent = '—';
return;
}
const list = getWinnerForYear(y);
const winners = Array.isArray(list) ? list : []; const winners = Array.isArray(list) ? list : [];
if (!winners.length) {
winnerCurrentEl.textContent = `${y}: (aucun gagnant)`;
return;
}
// Sort for stable display: points desc (null last), then name // Sort for stable display: points desc (null last), then name
const sorted = winners.slice().sort((a, b) => { return winners.slice().sort((a, b) => {
const ap = a?.points === null || a?.points === undefined ? null : Number(a.points); const ap = a?.points === null || a?.points === undefined ? null : Number(a.points);
const bp = b?.points === null || b?.points === undefined ? null : Number(b.points); const bp = b?.points === null || b?.points === undefined ? null : Number(b.points);
if (ap === null && bp !== null) return 1; if (ap === null && bp !== null) return 1;
@@ -1009,25 +998,57 @@ window.onload = async function () {
if (ap !== null && bp !== null && ap !== bp) return bp - ap; if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' }); return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' });
}); });
}
function renderWinnerCurrent() {
if (!winnerCurrentEl) return;
const years = Array.from(winnersByYear.keys())
.map((k) => Number(k))
.filter((n) => Number.isInteger(n))
.sort((a, b) => b - a);
if (!years.length) {
winnerCurrentEl.textContent = '—';
return;
}
const blocks = years.map((y) => {
const list = winnersByYear.get(String(y)) || [];
const sorted = sortWinnersForDisplay(list);
if (!sorted.length) {
return `
<div class="border rounded p-2 bg-white mb-2">
<div class="fw-semibold">${escapeHtml(String(y))}</div>
<div class="text-muted small">(aucun gagnant)</div>
</div>
`;
}
const rows = sorted.map((w) => { const rows = sorted.map((w) => {
const name = w?.name || '(utilisateur supprimé)'; const name = escapeHtml(w?.name || '(utilisateur supprimé)');
const pts = w?.points === null || w?.points === undefined ? null : Number(w.points); const pts = w?.points === null || w?.points === undefined ? null : Number(w.points);
const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` <span class="text-muted">— ${pts} pts</span>`; const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` <span class="text-muted">— ${escapeHtml(String(Math.round(pts)))} pts</span>`;
const uid = String(w?.userId || ''); const uid = escapeHtml(String(w?.userId || '').trim());
return (
`<div class="d-flex justify-content-between align-items-center border rounded px-2 py-1 mb-2 bg-white">` +
`<div class="me-2 min-width-0"><strong class="text-break">${name}</strong>${ptsLabel}</div>` +
`<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" data-winner-remove-year="${escapeHtml(String(y))}" data-winner-remove-user="${uid}">Retirer</button>` +
`</div>`
);
}).join('');
const tieLabel = sorted.length > 1 ? ' <span class="text-muted fw-normal">(égalité)</span>' : '';
return ` return `
<div class="d-flex justify-content-between align-items-center border rounded px-2 py-1 mb-2"> <div class="border rounded p-2 bg-light mb-2">
<div class="me-2"><strong>${name}</strong>${ptsLabel}</div> <div class="fw-semibold mb-2">${escapeHtml(String(y))}${tieLabel}</div>
<button type="button" class="btn btn-sm btn-outline-danger" data-winner-remove-year="${y}" data-winner-remove-user="${uid}">Retirer</button> ${rows}
</div> </div>
`; `;
}).join(''); }).join('');
const tieLabel = sorted.length > 1 ? ' <span class="text-muted fw-normal">(égalité)</span>' : ''; winnerCurrentEl.innerHTML = blocks;
winnerCurrentEl.innerHTML = `
<div class="fw-semibold mb-2">${y}${tieLabel}</div>
${rows}
`;
winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => { winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
@@ -1109,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);
@@ -1516,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) {
@@ -1915,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',
@@ -1953,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))
@@ -2167,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,
@@ -2235,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;
+34 -10
View File
@@ -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"> ${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
<div class="text-center pt-3 px-3"> <div class="text-center pt-3 px-3">
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}"> <img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
</div> </div>
</a> </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
View File
@@ -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);
+9 -7
View File
@@ -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>
+5
View File
@@ -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
View File
@@ -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);
+44 -14
View File
@@ -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,18 +146,34 @@ 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 : "";
const watchedCount = watchedMovies.length; if (!imdbId) return null;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1); const movie = movieByImdbId.get(imdbId);
if (!movie) return null;
const watchedMoviesDetails = watchedMovies.map((movie) => ({ return {
movieId: movie?._id ? String(movie._id) : null,
imdb_id: movie.imdb_id, imdb_id: movie.imdb_id,
title: movie.title 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 = watchedMoviesDetails.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
return { return {
name: user.name, name: user.name,
@@ -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 });