Merge pull request #92 from quoije/2026
Add movie runtime, bulk OMDb refresh, first-time setup and fix styling
This commit was merged in pull request #92.
This commit is contained in:
@@ -5,6 +5,7 @@ const movieSchema = new mongoose.Schema({
|
|||||||
title: { type: String, required: false },
|
title: { type: String, required: false },
|
||||||
description: { type: String, required: false },
|
description: { type: String, required: false },
|
||||||
rating: { type: String, required: false },
|
rating: { type: String, required: false },
|
||||||
|
runtime: { type: String, required: false },
|
||||||
poster: { type: String, required: false },
|
poster: { type: String, required: false },
|
||||||
cam: { type: Boolean, required: false, default: false },
|
cam: { type: Boolean, required: false, default: false },
|
||||||
// Oscar pool year / edition (used to segment movies + stats)
|
// Oscar pool year / edition (used to segment movies + stats)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-4
@@ -223,7 +223,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a href="control.html" class="nav-link btn btn-outline-light me-2 active">Admin</a>
|
<a href="control.html" class="nav-link btn btn-outline-light me-2 active">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -645,6 +645,7 @@
|
|||||||
|
|
||||||
<div class="col-12 col-md-8 col-xl-9 d-flex gap-2 justify-content-end">
|
<div class="col-12 col-md-8 col-xl-9 d-flex gap-2 justify-content-end">
|
||||||
<button type="button" id="refresh-movies" class="btn btn-outline-primary btn-compact">Rafraîchir</button>
|
<button type="button" id="refresh-movies" class="btn btn-outline-primary btn-compact">Rafraîchir</button>
|
||||||
|
<button type="button" id="update-omdb-selected" class="btn btn-outline-warning btn-compact" disabled>Refresh OMDb</button>
|
||||||
<button type="button" id="delete-selected" class="btn btn-danger btn-compact" disabled>Supprimer</button>
|
<button type="button" id="delete-selected" class="btn btn-danger btn-compact" disabled>Supprimer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1255,11 +1256,16 @@
|
|||||||
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
|
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<label for="edit_rating" class="form-label">Rating (imdbRating)</label>
|
<label for="edit_rating" class="form-label">Rating (imdbRating)</label>
|
||||||
<input type="text" class="form-control" id="edit_rating" placeholder="7.9">
|
<input type="text" class="form-control" id="edit_rating" placeholder="7.9">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 d-flex align-items-end">
|
<div class="col-md-4">
|
||||||
|
<label for="edit_runtime" class="form-label">Runtime</label>
|
||||||
|
<input type="text" class="form-control" id="edit_runtime" placeholder="142 min">
|
||||||
|
<div class="form-text">Ex: 142 min</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="edit_cam_flag">
|
<input class="form-check-input" type="checkbox" id="edit_cam_flag">
|
||||||
<label class="form-check-label" for="edit_cam_flag">Tag CAM</label>
|
<label class="form-check-label" for="edit_cam_flag">Tag CAM</label>
|
||||||
@@ -1270,7 +1276,7 @@
|
|||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="edit_refresh_omdb">
|
<input class="form-check-input" type="checkbox" id="edit_refresh_omdb">
|
||||||
<label class="form-check-label" for="edit_refresh_omdb">
|
<label class="form-check-label" for="edit_refresh_omdb">
|
||||||
Recharger depuis OMDb (écrase titre / plot / rating / poster)
|
Recharger depuis OMDb (écrase titre / plot / rating / runtime / poster)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.</div>
|
<div class="form-text">Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.</div>
|
||||||
|
|||||||
+24
-2
@@ -905,6 +905,22 @@ footer {
|
|||||||
.btn-warning:focus,
|
.btn-warning:focus,
|
||||||
.btn-warning:focus-visible {
|
.btn-warning:focus-visible {
|
||||||
box-shadow: 0 0 0 0.2rem rgba(29, 78, 216, 0.35);
|
box-shadow: 0 0 0 0.2rem rgba(29, 78, 216, 0.35);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:active,
|
||||||
|
.btn-warning.active,
|
||||||
|
.btn-warning:active:focus {
|
||||||
|
background-color: #1e40af;
|
||||||
|
border-color: #1e40af;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:focus:not(:focus-visible) {
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .badge.bg-light {
|
html[data-theme="dark"] .badge.bg-light {
|
||||||
@@ -1274,8 +1290,8 @@ footer {
|
|||||||
|
|
||||||
/* Dark mode: form-switch toggle visibility */
|
/* Dark mode: form-switch toggle visibility */
|
||||||
html[data-theme="dark"] .form-switch .form-check-input {
|
html[data-theme="dark"] .form-switch .form-check-input {
|
||||||
background-color: #495057;
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
border-color: #6c757d;
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.7%29'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.7%29'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,6 +1301,12 @@ footer {
|
|||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e");
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure form-check/form-switch has no background (inherits from parent container) */
|
||||||
|
.form-check,
|
||||||
|
.form-switch {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
+34
-1
@@ -47,7 +47,40 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card p-4" style="max-width: 400px; margin: auto;">
|
<!-- First-time setup card (hidden by default) -->
|
||||||
|
<div id="setup-card" class="card p-4 d-none" style="max-width: 450px; margin: auto;">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info mb-3">
|
||||||
|
<strong>Première installation</strong><br>
|
||||||
|
Aucun administrateur n'existe. Créez le premier compte admin pour commencer.
|
||||||
|
</div>
|
||||||
|
<form id="setup-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="setup-name" class="form-label">Nom</label>
|
||||||
|
<input type="text" class="form-control" id="setup-name" placeholder="Votre nom" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="setup-email" class="form-label">Courriel</label>
|
||||||
|
<input type="email" class="form-control" id="setup-email" placeholder="[email protected]" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="setup-password" class="form-label">Mot de passe</label>
|
||||||
|
<input type="password" class="form-control" id="setup-password" minlength="6" required>
|
||||||
|
<div class="form-text">Minimum 6 caractères</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="setup-password-confirm" class="form-label">Confirmer le mot de passe</label>
|
||||||
|
<input type="password" class="form-control" id="setup-password-confirm" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div id="setup-error" class="alert alert-danger d-none" role="alert"></div>
|
||||||
|
<button type="submit" class="btn btn-warning w-100">Créer le compte admin</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal login card -->
|
||||||
|
<div id="login-card" class="card p-4" style="max-width: 400px; margin: auto;">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ window.onload = async function () {
|
|||||||
const adminMoviesBody = document.getElementById('admin-movies-body');
|
const adminMoviesBody = document.getElementById('admin-movies-body');
|
||||||
const refreshMoviesBtn = document.getElementById('refresh-movies');
|
const refreshMoviesBtn = document.getElementById('refresh-movies');
|
||||||
const deleteSelectedBtn = document.getElementById('delete-selected');
|
const deleteSelectedBtn = document.getElementById('delete-selected');
|
||||||
|
const updateOmdbSelectedBtn = document.getElementById('update-omdb-selected');
|
||||||
const selectAllBox = document.getElementById('select-all-movies');
|
const selectAllBox = document.getElementById('select-all-movies');
|
||||||
const selectedCountEl = document.getElementById('selected-count');
|
const selectedCountEl = document.getElementById('selected-count');
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ window.onload = async function () {
|
|||||||
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
||||||
const editTitleEl = document.getElementById('edit_title');
|
const editTitleEl = document.getElementById('edit_title');
|
||||||
const editRatingEl = document.getElementById('edit_rating');
|
const editRatingEl = document.getElementById('edit_rating');
|
||||||
|
const editRuntimeEl = document.getElementById('edit_runtime');
|
||||||
const editPosterEl = document.getElementById('edit_poster');
|
const editPosterEl = document.getElementById('edit_poster');
|
||||||
const editDescriptionEl = document.getElementById('edit_description');
|
const editDescriptionEl = document.getElementById('edit_description');
|
||||||
const editCamFlagEl = document.getElementById('edit_cam_flag');
|
const editCamFlagEl = document.getElementById('edit_cam_flag');
|
||||||
@@ -2238,6 +2240,7 @@ window.onload = async function () {
|
|||||||
const selectedIds = getSelectedMovieIds();
|
const selectedIds = getSelectedMovieIds();
|
||||||
selectedCountEl.textContent = String(selectedIds.length);
|
selectedCountEl.textContent = String(selectedIds.length);
|
||||||
deleteSelectedBtn.disabled = selectedIds.length === 0;
|
deleteSelectedBtn.disabled = selectedIds.length === 0;
|
||||||
|
if (updateOmdbSelectedBtn) updateOmdbSelectedBtn.disabled = selectedIds.length === 0;
|
||||||
|
|
||||||
const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
|
const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
|
||||||
const checked = allBoxes.filter((b) => b.checked).length;
|
const checked = allBoxes.filter((b) => b.checked).length;
|
||||||
@@ -2324,6 +2327,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
editTitleEl.value = movie.title || '';
|
editTitleEl.value = movie.title || '';
|
||||||
editRatingEl.value = movie.rating || '';
|
editRatingEl.value = movie.rating || '';
|
||||||
|
if (editRuntimeEl) editRuntimeEl.value = movie.runtime || '';
|
||||||
editPosterEl.value = movie.poster || '';
|
editPosterEl.value = movie.poster || '';
|
||||||
editDescriptionEl.value = movie.description || '';
|
editDescriptionEl.value = movie.description || '';
|
||||||
if (editCamFlagEl) editCamFlagEl.checked = !!movie.cam;
|
if (editCamFlagEl) editCamFlagEl.checked = !!movie.cam;
|
||||||
@@ -2359,6 +2363,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
const title = editTitleEl.value;
|
const title = editTitleEl.value;
|
||||||
const rating = editRatingEl.value;
|
const rating = editRatingEl.value;
|
||||||
|
const runtime = editRuntimeEl ? editRuntimeEl.value : '';
|
||||||
const poster = editPosterEl.value;
|
const poster = editPosterEl.value;
|
||||||
const description = editDescriptionEl.value;
|
const description = editDescriptionEl.value;
|
||||||
|
|
||||||
@@ -2407,6 +2412,7 @@ window.onload = async function () {
|
|||||||
body.title = title;
|
body.title = title;
|
||||||
body.description = description;
|
body.description = description;
|
||||||
body.rating = rating;
|
body.rating = rating;
|
||||||
|
body.runtime = runtime;
|
||||||
body.poster = poster;
|
body.poster = poster;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2619,6 +2625,65 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateOmdbSelectedBtn) {
|
||||||
|
updateOmdbSelectedBtn.addEventListener('click', async () => {
|
||||||
|
const selectedIds = getSelectedMovieIds();
|
||||||
|
if (!selectedIds.length) return;
|
||||||
|
|
||||||
|
const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
|
||||||
|
const ok = window.confirm(`Rafraîchir ${selectedIds.length} film(s)${yearLabel} depuis OMDb ? Cette opération peut écraser les infos (titre, plot, rating, poster, runtime).`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
updateOmdbSelectedBtn.disabled = true;
|
||||||
|
updateOmdbSelectedBtn.textContent = 'Refresh OMDb...';
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const movieId of selectedIds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refreshOmdb: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `Erreur (${res.status})`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
msg = data.message || data.error || msg;
|
||||||
|
} catch (_) {}
|
||||||
|
errors.push(`${movieId}: ${msg}`);
|
||||||
|
errorCount++;
|
||||||
|
} else {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`${movieId}: ${err.message || 'Erreur réseau'}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOmdbSelectedBtn.disabled = false;
|
||||||
|
updateOmdbSelectedBtn.textContent = 'Refresh OMDb';
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
showResponse('success', `${successCount} film(s) mis à jour depuis OMDb.`);
|
||||||
|
} else if (successCount === 0) {
|
||||||
|
showResponse('danger', `Échec pour ${errorCount} film(s). ${errors.slice(0, 3).join('; ')}`);
|
||||||
|
} else {
|
||||||
|
showResponse('warning', `${successCount} succès, ${errorCount} échec(s). ${errors.slice(0, 2).join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadMoviesForManagement();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (saveMovieChangesBtn) {
|
if (saveMovieChangesBtn) {
|
||||||
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
|
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-6
@@ -22,9 +22,34 @@ async function applyActiveOscarYearToTitle() {
|
|||||||
// Fire and forget.
|
// Fire and forget.
|
||||||
applyActiveOscarYearToTitle();
|
applyActiveOscarYearToTitle();
|
||||||
|
|
||||||
// Check if the user is already logged in by looking for auth_token in localStorage
|
// Check setup status first
|
||||||
const existingToken = localStorage.getItem('auth_token');
|
async function checkSetupStatus() {
|
||||||
if (existingToken) {
|
try {
|
||||||
|
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
|
||||||
|
if (!res.ok) return { needsSetup: false };
|
||||||
|
return await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
return { needsSetup: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPage() {
|
||||||
|
const setupCard = document.getElementById('setup-card');
|
||||||
|
const loginCard = document.getElementById('login-card');
|
||||||
|
|
||||||
|
// Check if setup is needed
|
||||||
|
const status = await checkSetupStatus();
|
||||||
|
|
||||||
|
if (status.needsSetup) {
|
||||||
|
// Show setup card, hide login card
|
||||||
|
if (setupCard) setupCard.classList.remove('d-none');
|
||||||
|
if (loginCard) loginCard.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is already logged in
|
||||||
|
const existingToken = localStorage.getItem('auth_token');
|
||||||
|
if (existingToken) {
|
||||||
try {
|
try {
|
||||||
const decoded = decodeJwt(existingToken);
|
const decoded = decodeJwt(existingToken);
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
@@ -32,16 +57,74 @@ if (existingToken) {
|
|||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
} else if (decoded.mustChangePassword) {
|
} else if (decoded.mustChangePassword) {
|
||||||
window.location.href = '/change-password.html';
|
window.location.href = '/change-password.html';
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/movies.html';
|
window.location.href = '/movies.html';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle login form submission
|
// Initialize the page
|
||||||
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
initPage();
|
||||||
|
|
||||||
|
// Handle setup form submission
|
||||||
|
const setupForm = document.getElementById('setup-form');
|
||||||
|
if (setupForm) {
|
||||||
|
setupForm.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const setupError = document.getElementById('setup-error');
|
||||||
|
const name = document.getElementById('setup-name').value.trim();
|
||||||
|
const email = document.getElementById('setup-email').value.trim();
|
||||||
|
const password = document.getElementById('setup-password').value;
|
||||||
|
const passwordConfirm = document.getElementById('setup-password-confirm').value;
|
||||||
|
|
||||||
|
// Hide previous error
|
||||||
|
if (setupError) setupError.classList.add('d-none');
|
||||||
|
|
||||||
|
// Validate passwords match
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
if (setupError) {
|
||||||
|
setupError.textContent = 'Les mots de passe ne correspondent pas';
|
||||||
|
setupError.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/setup/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Save the token and redirect
|
||||||
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
window.location.href = '/movies.html';
|
||||||
|
} else {
|
||||||
|
if (setupError) {
|
||||||
|
setupError.textContent = data.message || 'Erreur lors de la création du compte';
|
||||||
|
setupError.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (setupError) {
|
||||||
|
setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
|
||||||
|
setupError.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login form submission
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const email = document.getElementById('email').value;
|
const email = document.getElementById('email').value;
|
||||||
@@ -67,4 +150,4 @@ if (existingToken) {
|
|||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`${error.message}`);
|
alert(`${error.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -160,6 +160,20 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRuntime(runtime) {
|
||||||
|
// Convert "142 min" or "142" to "2h 22min" format
|
||||||
|
if (!runtime || runtime === 'N/A') return '';
|
||||||
|
const raw = String(runtime).trim();
|
||||||
|
// Extract minutes from strings like "142 min", "142min", or just "142"
|
||||||
|
const match = raw.match(/^(\d+)\s*min/i);
|
||||||
|
const minutes = match ? parseInt(match[1], 10) : parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return raw;
|
||||||
|
if (minutes < 60) return `${minutes}min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h}h ${m}min` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPlaybackProgressByYear(year, token) {
|
async function fetchPlaybackProgressByYear(year, token) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
|
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
|
||||||
@@ -571,6 +585,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
|||||||
: `<span class="text-dark">${movie.title}</span>`
|
: `<span class="text-dark">${movie.title}</span>`
|
||||||
}
|
}
|
||||||
</h5>
|
</h5>
|
||||||
|
${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
|
||||||
<div class="movie-rating-block mt-1">
|
<div class="movie-rating-block mt-1">
|
||||||
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
|
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,34 @@ async function applyActiveOscarYearToTitle() {
|
|||||||
// Fire and forget.
|
// Fire and forget.
|
||||||
applyActiveOscarYearToTitle();
|
applyActiveOscarYearToTitle();
|
||||||
|
|
||||||
// Check if the user is already logged in by looking for auth_token in localStorage
|
// Check setup status
|
||||||
const existingToken = localStorage.getItem('auth_token');
|
async function checkSetupStatus() {
|
||||||
if (existingToken) {
|
try {
|
||||||
|
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
|
||||||
|
if (!res.ok) return { needsSetup: false };
|
||||||
|
return await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
return { needsSetup: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPage() {
|
||||||
|
const setupNeededCard = document.getElementById('setup-needed-card');
|
||||||
|
const registerCard = document.getElementById('register-card');
|
||||||
|
|
||||||
|
// Check if setup is needed
|
||||||
|
const status = await checkSetupStatus();
|
||||||
|
|
||||||
|
if (status.needsSetup) {
|
||||||
|
// Show setup-needed banner, hide registration form
|
||||||
|
if (setupNeededCard) setupNeededCard.classList.remove('d-none');
|
||||||
|
if (registerCard) registerCard.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||||
|
const existingToken = localStorage.getItem('auth_token');
|
||||||
|
if (existingToken) {
|
||||||
try {
|
try {
|
||||||
const decoded = decodeJwt(existingToken);
|
const decoded = decodeJwt(existingToken);
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
@@ -32,14 +57,20 @@ if (existingToken) {
|
|||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
} else if (decoded.mustChangePassword) {
|
} else if (decoded.mustChangePassword) {
|
||||||
window.location.href = '/change-password.html';
|
window.location.href = '/change-password.html';
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/movies.html';
|
window.location.href = '/movies.html';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the page
|
||||||
|
initPage();
|
||||||
|
|
||||||
// Handle registration form submission
|
// Handle registration form submission
|
||||||
document.getElementById('register-form').addEventListener('submit', async function (e) {
|
document.getElementById('register-form').addEventListener('submit', async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -214,7 +214,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-1
@@ -40,7 +40,20 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="container d-flex justify-content-center align-items-center vh-100">
|
<div class="container d-flex justify-content-center align-items-center vh-100">
|
||||||
<div class="card p-4" style="max-width: 400px;">
|
<!-- Setup needed banner (hidden by default) -->
|
||||||
|
<div id="setup-needed-card" class="card p-4 d-none" style="max-width: 400px;">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning mb-3">
|
||||||
|
<strong>Configuration requise</strong><br>
|
||||||
|
L'application doit d'abord être configurée par un administrateur.
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-primary w-100">Aller à la page de configuration</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal registration card -->
|
||||||
|
<div id="register-card" class="card p-4" style="max-width: 400px;">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
|
||||||
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
|
||||||
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
|
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Stats</a>
|
||||||
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;">Mes picks</a>
|
||||||
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
|
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
|
||||||
<a href="view-picks.html" class="nav-link btn btn-info me-2 active">Voir Tous les Choix</a>
|
<a href="view-picks.html" class="nav-link btn btn-info me-2 active">Voir Tous les Choix</a>
|
||||||
|
|||||||
+7
-1
@@ -242,6 +242,7 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
|
|||||||
title: response.data.Title,
|
title: response.data.Title,
|
||||||
description: response.data.Plot,
|
description: response.data.Plot,
|
||||||
rating: response.data.imdbRating,
|
rating: response.data.imdbRating,
|
||||||
|
runtime: response.data.Runtime,
|
||||||
poster: response.data.Poster
|
poster: response.data.Poster
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -289,7 +290,7 @@ router.get("/", async (req, res) => {
|
|||||||
|
|
||||||
const projection = isChecklist
|
const projection = isChecklist
|
||||||
? "imdb_id title"
|
? "imdb_id title"
|
||||||
: "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt";
|
: "imdb_id title description rating runtime poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt";
|
||||||
|
|
||||||
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
|
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
|
||||||
const cached = cacheGet(cacheKey);
|
const cached = cacheGet(cacheKey);
|
||||||
@@ -583,6 +584,7 @@ router.post("/add", async (req, res) => {
|
|||||||
title: String(imdb_id || "").trim() || "Untitled",
|
title: String(imdb_id || "").trim() || "Untitled",
|
||||||
description: "",
|
description: "",
|
||||||
rating: "",
|
rating: "",
|
||||||
|
runtime: "",
|
||||||
poster: ""
|
poster: ""
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -596,6 +598,7 @@ router.post("/add", async (req, res) => {
|
|||||||
title: movieDetails.title,
|
title: movieDetails.title,
|
||||||
description: movieDetails.description,
|
description: movieDetails.description,
|
||||||
rating: movieDetails.rating,
|
rating: movieDetails.rating,
|
||||||
|
runtime: movieDetails.runtime,
|
||||||
poster: movieDetails.poster,
|
poster: movieDetails.poster,
|
||||||
cam: !!cam,
|
cam: !!cam,
|
||||||
year: parsedYear,
|
year: parsedYear,
|
||||||
@@ -852,6 +855,7 @@ router.put("/:id", async (req, res) => {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
rating,
|
rating,
|
||||||
|
runtime,
|
||||||
poster,
|
poster,
|
||||||
year,
|
year,
|
||||||
category,
|
category,
|
||||||
@@ -1008,6 +1012,7 @@ router.put("/:id", async (req, res) => {
|
|||||||
movie.title = details.title;
|
movie.title = details.title;
|
||||||
movie.description = details.description;
|
movie.description = details.description;
|
||||||
movie.rating = details.rating;
|
movie.rating = details.rating;
|
||||||
|
movie.runtime = details.runtime;
|
||||||
movie.poster = details.poster;
|
movie.poster = details.poster;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
||||||
@@ -1019,6 +1024,7 @@ router.put("/:id", async (req, res) => {
|
|||||||
if (title !== undefined) movie.title = title;
|
if (title !== undefined) movie.title = title;
|
||||||
if (description !== undefined) movie.description = description;
|
if (description !== undefined) movie.description = description;
|
||||||
if (rating !== undefined) movie.rating = rating;
|
if (rating !== undefined) movie.rating = rating;
|
||||||
|
if (runtime !== undefined) movie.runtime = runtime;
|
||||||
if (poster !== undefined) movie.poster = poster;
|
if (poster !== undefined) movie.poster = poster;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,90 @@ async function getOrInitActiveYear() {
|
|||||||
return year;
|
return year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FIRST-TIME SETUP ENDPOINTS
|
||||||
|
// These allow creating the first admin user when the database is empty/new
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Check if the app needs initial setup (no admin users exist)
|
||||||
|
router.get("/setup/status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const totalUsers = await User.countDocuments({});
|
||||||
|
const adminCount = await User.countDocuments({ role: 69 });
|
||||||
|
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
return res.status(200).json({
|
||||||
|
needsSetup: adminCount === 0,
|
||||||
|
hasUsers: totalUsers > 0,
|
||||||
|
hasAdmin: adminCount > 0,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the first admin user (only works if no admin exists)
|
||||||
|
router.post("/setup/admin", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if an admin already exists
|
||||||
|
const adminCount = await User.countDocuments({ role: 69 });
|
||||||
|
if (adminCount > 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Un administrateur existe déjà. Cette fonctionnalité est désactivée.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password } = req.body;
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const nameNorm = typeof name === "string" ? name.trim() : "";
|
||||||
|
const emailNorm = typeof email === "string" ? email.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
if (!nameNorm) {
|
||||||
|
return res.status(400).json({ message: "Le nom est obligatoire" });
|
||||||
|
}
|
||||||
|
if (!emailNorm) {
|
||||||
|
return res.status(400).json({ message: "L'email est obligatoire" });
|
||||||
|
}
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
||||||
|
return res.status(400).json({ message: "Email invalide" });
|
||||||
|
}
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
return res.status(400).json({ message: "Le mot de passe doit contenir au moins 6 caractères" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is already taken
|
||||||
|
const existing = await User.findOne({ email: emailNorm });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ message: "Cet email est déjà utilisé" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the admin user
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const adminUser = new User({
|
||||||
|
name: nameNorm,
|
||||||
|
email: emailNorm,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: 69, // Admin role
|
||||||
|
mustChangePassword: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await adminUser.save();
|
||||||
|
|
||||||
|
console.log(`[setup] First admin user created: ${emailNorm}`);
|
||||||
|
|
||||||
|
// Return a token so the admin is logged in immediately
|
||||||
|
const token = signUserToken(adminUser);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
message: "Administrateur créé avec succès",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user