Mr. Worldwide #93

Merged
quoije merged 10 commits from 2026-localization into prod 2026-03-18 20:44:34 -04:00
31 changed files with 3063 additions and 811 deletions
Showing only changes of commit 4308d6babf - Show all commits
+14
View File
@@ -165,6 +165,13 @@ eng.traineddata
Thumbs.db
desktop.ini
# VS Code project settings
.vscode/
# AI assistant/editor metadata
.claude/
.cursor/
# Python cache files
__pycache__/
*.pyc
@@ -174,3 +181,10 @@ __pycache__/
# Temporary scripts
temp/
# Localization helper artifacts (not part of app)
BEFORE_AFTER_COMPARISON.md
LOCALIZATION_COMPLETE.md
LOCALIZATION_PROGRESS.md
VERIFICATION_REPORT.md
localize_remaining.py
+28 -11
View File
@@ -3,13 +3,31 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Changer le mot de passe</title>
<title data-i18n="changePassword.title">Oscar Pool - Change Password</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
</head>
<body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -34,29 +52,29 @@
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 420px; width: 100%;">
<div class="text-center mb-3">
<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="Oscar Pool">
</div>
<div class="alert alert-warning" role="alert">
Votre compte utilise un <strong>mot de passe temporaire</strong>. Veuillez définir un nouveau mot de passe pour continuer.
<div class="alert alert-warning" role="alert" data-i18n="changePassword.tempPassword">
Your account uses a <strong>temporary password</strong>. Please set a new password to continue.
</div>
<div id="change-password-error" class="alert alert-danger d-none" role="alert"></div>
<form id="change-password-form">
<div class="mb-3">
<label for="new-password" class="form-label">Nouveau mot de passe</label>
<label for="new-password" class="form-label" data-i18n="changePassword.newPassword">New password</label>
<input type="password" class="form-control" id="new-password" minlength="8" required>
<div class="form-text">Minimum 8 caractères.</div>
<div class="form-text" data-i18n="changePassword.minChars">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label for="confirm-password" class="form-label">Confirmer le mot de passe</label>
<label for="confirm-password" class="form-label" data-i18n="auth.confirmPassword">Confirm password</label>
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
<button type="submit" class="btn btn-primary w-100" data-i18n="common.save">Save</button>
</form>
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3">Déconnexion</button>
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3" data-i18n="nav.logout">Log out</button>
</div>
</div>
@@ -64,4 +82,3 @@
<script src="./js/app_change-password.js"></script>
</body>
</html>
+41 -22
View File
@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Checklist</title>
<title data-i18n="checklist.title">Oscar Pool - Checklist</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -52,7 +70,7 @@
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
@@ -71,16 +89,16 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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 id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -101,7 +119,7 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
@@ -113,8 +131,9 @@
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
<div class="text-muted small" id="time-until-oscars-container">
<span data-i18n="movies.timeUntilOscarsLabel">Time until the Oscars</span>
&nbsp;(<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
<div class="text-muted small"><span id="movies-per-day"></span></div>
</div>
@@ -130,14 +149,14 @@
<table class="table table-striped table-hover align-middle movie-table mb-0">
<thead>
<tr>
<th scope="col">Vu</th>
<th scope="col">Titre</th>
<th scope="col">Date</th>
<th scope="col" data-i18n="checklist.seen">Seen</th>
<th scope="col" data-i18n="checklist.filmTitle">Title</th>
<th scope="col" data-i18n="checklist.date">Date</th>
</tr>
</thead>
<tbody id="movie-table-body">
<tr>
<td colspan="3" 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" data-i18n="common.loading">Loading…</td>
</tr>
</tbody>
</table>
@@ -154,15 +173,15 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uncheckConfirmModalLabel">Confirmer le retrait</h5>
<h5 class="modal-title" id="uncheckConfirmModalLabel" data-i18n="checklist.confirmRemoval">Confirm removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Es-tu sûr de vouloir retirer <strong id="uncheckMovieTitle"></strong> de ta liste?</p>
<p id="uncheck-confirm-text">Are you sure you want to remove <strong id="uncheckMovieTitle"></strong> from your list?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-danger" id="confirmUncheckBtn">Retirer</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmUncheckBtn" data-i18n="common.remove">Remove</button>
</div>
</div>
</div>
@@ -173,7 +192,7 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="videoModalLabel">Félicitations! very nice 🎉🎉🎉</h5>
<h5 class="modal-title" id="videoModalLabel" data-i18n="checklist.congratulations">Congratulations! very nice 🎉🎉🎉</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
@@ -182,7 +201,7 @@
<video id="rewardVideo" width="75%" controls class="d-none">
<source id="rewardVideoSource" src="video/reward.mp4" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
<span data-i18n="checklist.browserNoVideo">Your browser does not support video.</span>
</video>
</div>
</div>
@@ -191,7 +210,7 @@
<!-- Footer Section -->
<footer>
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
+325 -273
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -399,6 +399,35 @@ footer {
border-color: rgba(0, 0, 0, 0.14);
}
/* Smooth navbar transition while i18n loads
- Keep navbar visible but subtly blurred/faded to avoid abrupt disappearance.
- Make elements non-interactive until translations are applied. */
body:not(.i18n-ready) nav.navbar,
body:not(.i18n-ready) .navbar,
body:not(.i18n-ready) .navbar .navbar-brand,
body:not(.i18n-ready) .navbar .nav-link,
body:not(.i18n-ready) .navbar .navbar-text,
body:not(.i18n-ready) .navbar .btn {
transition: filter 220ms ease, opacity 220ms ease, transform 220ms ease;
filter: blur(6px) saturate(0.9) grayscale(0.06);
opacity: 0.85;
transform: translateY(-2px);
pointer-events: none;
}
/* When translations are ready, restore full clarity and interactivity */
body.i18n-ready nav.navbar,
body.i18n-ready .navbar,
body.i18n-ready .navbar .navbar-brand,
body.i18n-ready .navbar .nav-link,
body.i18n-ready .navbar .navbar-text,
body.i18n-ready .navbar .btn {
filter: none;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.stats-movie-poster {
width: 56px;
height: 84px;
+34 -18
View File
@@ -3,11 +3,26 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars</title>
<title data-i18n="common.oscarPool">Oscar Pool</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
/* Ensure the body takes the full height */
html, body {
height: 100%;
@@ -22,9 +37,10 @@
}
</style>
<script src="./js/localization.js" defer></script>
</head>
<body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -50,52 +66,52 @@
<!-- 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">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</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.
<strong data-i18n="auth.firstTimeSetup">First time setup</strong><br>
<span data-i18n="auth.noAdminExists">No administrator exists. Create the first admin account to get started.</span>
</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>
<label for="setup-name" class="form-label" data-i18n="auth.name">Name</label>
<input type="text" class="form-control" id="setup-name" data-i18n-placeholder="auth.namePlaceholder" placeholder="Your name" 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="admin@exemple.com" required>
<label for="setup-email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="setup-email" data-i18n-placeholder="auth.emailPlaceholder" placeholder="admin@example.com" required>
</div>
<div class="mb-3">
<label for="setup-password" class="form-label">Mot de passe</label>
<label for="setup-password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="setup-password" minlength="6" required>
<div class="form-text">Minimum 6 caractères</div>
<div class="form-text" data-i18n="auth.passwordMin" data-i18n-params='{"min": 6}'>Minimum 6 characters</div>
</div>
<div class="mb-3">
<label for="setup-password-confirm" class="form-label">Confirmer le mot de passe</label>
<label for="setup-password-confirm" class="form-label" data-i18n="auth.confirmPassword">Confirm password</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>
<button type="submit" class="btn btn-warning w-100" data-i18n="auth.createAdminAccount">Create admin account</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">
<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="Oscar Pool">
</div>
<form id="login-form">
<div class="mb-3">
<label for="email" class="form-label">Courriel</label>
<label for="email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<label for="password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Connexion</button>
<button type="submit" class="btn btn-primary w-100" data-i18n="auth.login">Login</button>
</form>
<p class="mt-3 text-center">Pas de compte? <a href="/register.html">Enregistrez-vous</a></p>
<p class="mt-3 text-center"><span data-i18n="auth.noAccount">No account?</span> <a href="/register.html" data-i18n="auth.signUp">Sign up</a></p>
</div>
</div>
+17 -3
View File
@@ -2,6 +2,20 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function isTokenExpired(decoded) {
const currentTime = Math.floor(Date.now() / 1000);
return !!decoded.exp && decoded.exp < currentTime;
@@ -81,11 +95,11 @@ window.onload = function () {
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword.length < 8) {
showError('Le mot de passe doit contenir au moins 8 caractères.');
showError(t('auth.passwordMinLength', 'Password must be at least 8 characters.'));
return;
}
if (newPassword !== confirmPassword) {
showError('Les mots de passe ne correspondent pas.');
showError(t('auth.passwordsDoNotMatch', 'Passwords do not match'));
return;
}
@@ -116,7 +130,7 @@ window.onload = function () {
window.location.href = '/movies.html';
} catch (err) {
showError(err.message || 'Erreur réseau');
showError(err.message || t('auth.networkError', 'Network error'));
}
});
};
+78 -21
View File
@@ -1,7 +1,21 @@
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -119,8 +133,8 @@ function createPageLoader(options = {}) {
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement de la checklist',
subtitle: 'Récupération des données…'
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.loadingChecklist') : 'Loading checklist',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
const token = localStorage.getItem('auth_token');
@@ -133,7 +147,7 @@ window.addEventListener('DOMContentLoaded', async function () {
<tr>
<td colspan="3">
<div class="alert alert-danger my-2" role="alert">
${message || 'Erreur lors du chargement.'}
${message || t('common.errorLoading', 'Error loading.')}
</div>
</td>
</tr>
@@ -141,7 +155,7 @@ window.addEventListener('DOMContentLoaded', async function () {
}
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉',
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.congratulations') : 'Congratulations! very nice 🎉🎉🎉',
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: ''
@@ -265,16 +279,20 @@ window.addEventListener('DOMContentLoaded', async function () {
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
const currentDate = new Date();
let timeDifference = fallbackDate - currentDate;
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
@@ -283,13 +301,17 @@ window.addEventListener('DOMContentLoaded', async function () {
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
const now = new Date();
timeDifference = targetDate - now;
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
if (token) {
@@ -333,7 +355,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalCount = Number(summary?.totalMoviesCount) || 0;
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
if (watchedRatioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedCount, total: totalCount, percent: ratioPct })
: `Watched: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
watchedRatioEl.innerText = txt;
}
updateProgressBar(watchedCount, totalCount, false);
pageLoader.setProgress(32);
return summary;
@@ -341,7 +368,7 @@ window.addEventListener('DOMContentLoaded', async function () {
.catch(() => null);
// Fetch remaining data in parallel.
pageLoader.setSubtitle('Chargement de la liste des films…');
pageLoader.setSubtitle(t('checklist.loadingMovies', 'Loading movies…'));
pageLoader.setProgress(26);
const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(),
@@ -372,8 +399,8 @@ window.addEventListener('DOMContentLoaded', async function () {
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
} catch (e) {
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.');
showTableError((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoad') : 'Unable to load checklist. Please try again later.');
pageLoader.fail((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoadNetwork') : 'Unable to load checklist. Check your connection and try again.');
return;
}
@@ -383,7 +410,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
if (document.getElementById('watched-ratio')) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = txt;
}
function launchConfetti() {
const duration = 3 * 1000; // 3 seconds
@@ -522,12 +554,22 @@ window.addEventListener('DOMContentLoaded', async function () {
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount);
const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
}
});
@@ -562,20 +604,35 @@ window.addEventListener('DOMContentLoaded', async function () {
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount);
// Recalculate and update movies per day
const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
}
});
});
const moviesLeft = totalMoviesCount - watchedMoviesCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(92);
+310 -164
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -86,10 +86,12 @@
// Show button immediately based on cached value
if (cachedValue.showPicksButton !== false) {
picksButton.style.display = '';
} else {
picksButton.style.display = 'none';
}
} else {
// Default to showing while we fetch
picksButton.style.display = '';
// Default to hiding while we fetch (new installs should start disabled)
picksButton.style.display = 'none';
}
// Fetch fresh value from server
+38 -4
View File
@@ -2,6 +2,39 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
"L'utilisateur n'a pas été trouvé": 'auth.userNotFound',
'User not found': 'auth.userNotFound',
"Informations d'identification non valides": 'auth.invalidCredentials',
'Invalid credentials': 'auth.invalidCredentials',
"Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.": 'auth.tempPasswordExpired',
'Temporary password expired. Ask an admin to reset your password.': 'auth.tempPasswordExpired',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
}
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
@@ -110,13 +143,13 @@ if (setupForm) {
window.location.href = '/movies.html';
} else {
if (setupError) {
setupError.textContent = data.message || 'Erreur lors de la création du compte';
setupError.textContent = data.message || t('auth.errorCreatingAccount', 'Error creating account');
setupError.classList.remove('d-none');
}
}
} catch (err) {
if (setupError) {
setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
setupError.textContent = t('auth.networkError', 'Network error. Please try again.');
setupError.classList.remove('d-none');
}
}
@@ -147,7 +180,8 @@ document.getElementById('login-form').addEventListener('submit', async function
window.location.href = '/movies.html'; // Redirect to movies page on success
}
} else {
const error = await res.json();
alert(`${error.message}`);
const error = await res.json().catch(() => ({}));
const msg = translateApiMessage(error.messageKey, error.message, 'auth.loginFailed', 'Login failed');
alert(msg);
}
});
+77 -25
View File
@@ -1,7 +1,21 @@
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -147,8 +161,8 @@ async function waitForImagesIn(containerEl, onProgress) {
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des films',
subtitle: 'Récupération des données…'
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.loadingMovies') : 'Loading movies',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
function formatClock(seconds) {
@@ -272,7 +286,11 @@ window.addEventListener('DOMContentLoaded', async function () {
if (avgEl) {
if (avg && count > 0) {
avgEl.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const avgLabel = `${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const translated = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: avgLabel })
: `User rating ${avgLabel}`;
avgEl.textContent = translated;
avgEl.classList.remove('movie-rating-sub--empty');
} else {
avgEl.textContent = '';
@@ -335,7 +353,12 @@ window.addEventListener('DOMContentLoaded', async function () {
if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
if (!Number.isNaN(d.getTime())) {
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
try {
lastUpdatedEl.textContent = d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
lastUpdatedEl.textContent = d.toLocaleString();
}
} else {
lastUpdatedEl.textContent = '—';
}
@@ -347,7 +370,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
if (ratioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watched, total: total, percent: ratioPct })
: `Watched: ${watched} / ${total} (${ratioPct}%)`;
ratioEl.innerText = txt;
}
}
const activeYear = await fetchActiveYear();
@@ -361,16 +389,18 @@ window.addEventListener('DOMContentLoaded', async function () {
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
@@ -380,14 +410,16 @@ window.addEventListener('DOMContentLoaded', async function () {
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
@@ -473,8 +505,11 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = ratioText;
const ratioText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.innerText = ratioText;
if (moviesList) moviesList.innerHTML = '';
@@ -501,6 +536,11 @@ window.addEventListener('DOMContentLoaded', async function () {
Number.isFinite(userRatingAvg) && userRatingAvg > 0
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
: '';
const avgHtml = userRatingText
? ((window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: userRatingText })
: `User rating ${userRatingText}`)
: '';
const userRatingOwn =
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
const starsHtml = Array.from({ length: 5 }, (_, idx) => {
@@ -531,10 +571,13 @@ window.addEventListener('DOMContentLoaded', async function () {
// Hide "resume/progress" once we're basically at the credits.
// Backend uses 95% as the "credit roll" threshold too.
if (pct < 95) {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center text-muted small">
<span>Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span></span>
<span>${resumeText}</span>
<span>${pct.toFixed(0)}%</span>
</div>
<div class="progress" style="height: 6px;">
@@ -544,9 +587,12 @@ window.addEventListener('DOMContentLoaded', async function () {
`;
}
} else {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2 text-muted small">
Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span>
${resumeText}
</div>
`;
}
@@ -554,6 +600,12 @@ window.addEventListener('DOMContentLoaded', async function () {
}
const camBadge = movie?.cam ? '<span class="badge bg-warning text-dark position-absolute top-0 end-0 m-2 cam-badge">CAM</span>' : '';
const watchedBadge = isChecked
? `<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.watched') : 'WATCHED'}</span>`
: '';
const noSourceHtml = !isClickable
? `<div class="text-muted small mb-1">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.noSource') : 'No source'}</div>`
: '';
const posterBlock = `
<div class="text-center pt-3 px-3 position-relative movie-poster-wrap">
${camBadge}
@@ -566,7 +618,7 @@ window.addEventListener('DOMContentLoaded', async function () {
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = `
<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>' : ''}
${watchedBadge}
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
@@ -587,26 +639,26 @@ window.addEventListener('DOMContentLoaded', async function () {
</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-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${avgHtml ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${avgHtml ? avgHtml : ''}</div>
</div>
</div>
<div class="movie-imdb-wrap">
<div class="movie-imdb-block">
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'}">
<span class="imdb-icon" aria-hidden="true">IMDb</span>
</a>
</div>
<div class="movie-rating-stars" role="group" aria-label="Noter ce film" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
<div class="movie-rating-stars" role="group" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('player.rateThisFilm') : 'Rate this film'}" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
${starsHtml}
</div>
</div>
</div>
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
${noSourceHtml}
<div class="movie-info-box">
<details class="movie-info-categories">
<summary class="movie-info-summary">
<span class="movie-info-summary-title">Catégories nominées</span>
<span class="movie-info-summary-title">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.nominatedCategories') : 'Nominated categories'}</span>
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
</summary>
<div class="movie-info-category">${movie.category}</div>
@@ -615,7 +667,7 @@ window.addEventListener('DOMContentLoaded', async function () {
</div>
</div>
<div class="mt-auto">
${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">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.watchedOn') : 'Watched on:'}</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div>
@@ -658,8 +710,8 @@ window.addEventListener('DOMContentLoaded', async function () {
});
pageLoader.done();
} catch (e) {
setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.');
setLoadError(moviesList, t('movies.errorLoadingList', 'Unable to load movie list. Try again in a moment.'));
pageLoader.fail(t('movies.errorLoading', 'Unable to load movies. Check your connection and try again.'));
}
document.getElementById('log-off').addEventListener('click', function () {
+74 -49
View File
@@ -2,9 +2,9 @@
'use strict';
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -120,6 +120,21 @@
return { setProgress, setSubtitle, done, fail };
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
@@ -198,7 +213,7 @@
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes picks`;
document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
}
if (pageLoader) pageLoader.setProgress(18);
@@ -225,10 +240,12 @@
document.getElementById('current-year').textContent = currentYear;
if (categories.length === 0) {
const noCats = t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear });
const adminHint = t('picks.adminMustImportCategories', 'An administrator must first import the categories.');
document.getElementById('categories-container').innerHTML = `
<div class="alert alert-warning">
Aucune catégorie trouvée pour l'année ${currentYear}.
Un administrateur doit d'abord importer les catégories.
${noCats}
${adminHint}
</div>
`;
if (pageLoader) pageLoader.done();
@@ -243,7 +260,7 @@
if (pageLoader) pageLoader.setProgress(85);
} catch (err) {
console.error('Error fetching categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
if (pageLoader) pageLoader.fail();
}
}
@@ -287,7 +304,7 @@
if (!container) return;
if (categories.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-muted">Aucune catégorie disponible</div>';
container.innerHTML = `<div class="text-center py-4 text-muted">${t('picks.noCategoriesAvailable', 'No categories available')}</div>`;
return;
}
@@ -297,9 +314,9 @@
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Choix</th>
<th>Nommés</th>
<th>${t('picks.category', 'Category')}</th>
<th style="width: 200px;">${t('picks.choice', 'Choice')}</th>
<th>${t('picks.nominees', 'Nominees')}</th>
</tr>
</thead>
<tbody>
@@ -314,7 +331,7 @@
<span class="selected-choice-badge badge bg-success">
<span>${selectedNominee}</span>
</span>
` : '<span class="text-muted fst-italic">Aucun choix</span>'}
` : `<span class="text-muted fst-italic">${t('picks.noChoice', 'No choice')}</span>`}
</td>
<td>
<div class="d-flex flex-wrap gap-2">
@@ -386,7 +403,7 @@
} catch (err) {
console.error('Auto-save failed:', err);
// Show error alert only on failure
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger');
} finally {
isSaving = false;
}
@@ -413,7 +430,7 @@
// Skip confirmation dialog in silent mode (auto-save)
if (!silent && picks.length < categories.length) {
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
if (!confirm(t('picks.incompleteSelection', `You have selected only ${picks.length} out of ${categories.length} categories. Do you want to save anyway?`, { selected: picks.length, total: categories.length }))) {
return;
}
}
@@ -438,18 +455,18 @@
const data = await res.json();
if (!silent) {
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success');
}
} catch (err) {
console.error('Error submitting picks:', err);
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger');
}
}
// Event listeners
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
showAlert(t('picks.reloaded', 'Picks reloaded'), 'info');
});
// Winners & Scores functionality (admin only)
@@ -480,7 +497,7 @@
await fetchScores();
} catch (err) {
console.error('Error fetching winners categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
}
}
@@ -489,7 +506,7 @@
if (!container) return;
if (winnersCategories.length === 0) {
container.innerHTML = '<div class="alert alert-warning">Aucune catégorie trouvée pour cette année.</div>';
container.innerHTML = `<div class="alert alert-warning">${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}</div>`;
return;
}
@@ -506,9 +523,9 @@
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Sélection</th>
<th>Gagnant</th>
<th>${t('picks.categoryHeader', 'Category')}</th>
<th style="width: 200px;">${t('picks.selectionHeader', 'Selection')}</th>
<th>${t('picks.winnerHeader', 'Winner')}</th>
</tr>
</thead>
<tbody>
@@ -559,13 +576,18 @@
throw new Error(error.message || 'Failed to update winner');
}
showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', 'success');
showAlert(
winnerName
? t('picks.winnerMarked', 'Winner marked successfully')
: t('picks.winnerDeleted', 'Winner deleted'),
'success'
);
await fetchWinnersCategories();
// Auto-refresh scores after marking/clearing a winner (silent mode)
await calculateScores(true);
} catch (err) {
console.error('Error updating winner:', err);
showAlert('Erreur: ' + err.message, 'danger');
showAlert(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger');
}
});
});
@@ -580,7 +602,7 @@
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = true;
btn.textContent = 'Calcul en cours...';
btn.textContent = t('picks.calculatingScores', 'Calculating scores…');
}
const res = await fetch('/api/picks/calculate-scores', {
@@ -598,7 +620,7 @@
if (error.message && error.message.includes('No winners marked')) {
if (!silent) {
// Only show as info/warning, not error
showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info');
showAlert(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info');
}
// Still try to fetch existing scores
await fetchScores();
@@ -609,19 +631,22 @@
const data = await res.json();
if (!silent) {
showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
showAlert(
t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }),
'success'
);
}
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
if (!silent) {
showAlert('Erreur: ' + err.message, 'danger');
showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger');
}
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = false;
btn.textContent = 'Calculer les scores';
btn.textContent = t('picks.calculateScores', 'Calculate scores');
}
}
}
@@ -653,7 +678,7 @@
console.error('Error fetching scores:', err);
const container = document.getElementById('scores-container');
if (container) {
container.innerHTML = '<div class="text-danger">Erreur lors du chargement des scores.</div>';
container.innerHTML = `<div class="text-danger">${t('picks.errorLoadingScores', 'Error loading scores.')}</div>`;
}
}
}
@@ -684,7 +709,7 @@
}).sort((a, b) => b.actualScore - a.actualScore);
if (validScores.length === 0) {
container.innerHTML = '<div class="text-muted">Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.</div>';
container.innerHTML = `<div class="text-muted">${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}</div>`;
return;
}
@@ -694,7 +719,7 @@
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>Utilisateur</th>
<th>${t('picks.userHeader', 'User')}</th>
<th class="text-center" style="width: 80px;">Score</th>
<th class="text-center" style="width: 70px;">Total</th>
<th class="text-center" style="width: 100px;">Actions</th>
@@ -714,7 +739,7 @@
<td class="text-center text-muted small">${totalCategories}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
Détails
${t('picks.detailsButton', 'Details')}
</button>
</td>
</tr>
@@ -743,10 +768,10 @@
const modalTitle = document.getElementById('userPicksModalLabel');
const modalContent = document.getElementById('user-picks-modal-content');
modalTitle.textContent = `Choix de ${score.userName}`;
modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName });
if (!score.pickDetails || score.pickDetails.length === 0) {
modalContent.innerHTML = '<div class="text-muted">Aucun détail disponible.</div>';
modalContent.innerHTML = `<div class="text-muted">${t('picks.noDetailsAvailable', 'No details available.')}</div>`;
modal.show();
return;
}
@@ -763,11 +788,11 @@
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-1">Résumé</h6>
<h6 class="mb-1">${t('picks.summaryTitle', 'Summary')}</h6>
<div class="text-muted small">
<span class="text-success"><strong>${correctPicks.length}</strong> correct(s)</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> incorrect(s)</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> total</span>
<span class="text-success"><strong>${correctPicks.length}</strong> ${t('picks.correctCountLabel', 'correct')}</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> ${t('picks.incorrectCountLabel', 'incorrect')}</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> ${t('picks.totalCountLabel', 'total')}</span>
</div>
</div>
<div class="text-end">
@@ -781,7 +806,7 @@
<div class="mb-4">
<h6 class="text-success mb-3">
<span class="badge bg-success me-2">✓</span>
Corrects (${correctPicks.length})
${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
</h6>
<div class="list-group">
${correctPicks.map(pick => `
@@ -804,7 +829,7 @@
<div class="mb-4">
<h6 class="text-danger mb-3">
<span class="badge bg-danger me-2">✗</span>
Incorrects (${incorrectPicks.length})
${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
</h6>
<div class="list-group">
${incorrectPicks.map(pick => `
@@ -814,13 +839,13 @@
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-2">
<div class="mb-1">
<span class="badge bg-danger">✗ Choix: ${pick.selectedNominee}</span>
<span class="badge bg-danger">✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}</span>
</div>
${pick.correctWinner ? `
<div>
<span class="badge bg-success">✓ Gagnant: ${pick.correctWinner}</span>
<span class="badge bg-success">✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}</span>
</div>
` : '<div class="text-muted small">Aucun gagnant marqué</div>'}
` : `<div class="text-muted small">${t('picks.noWinnerMarkedShort', 'No winner marked')}</div>`}
</div>
</div>
</div>
@@ -832,7 +857,7 @@
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
<div class="alert alert-info">
Aucun choix à afficher.
${t('picks.noChoicesToShow', 'No picks to show.')}
</div>
` : ''}
`;
@@ -843,7 +868,7 @@
// Event listeners for winners tab
document.getElementById('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert('Données rafraîchies', 'info');
showAlert(t('picks.dataRefreshed', 'Data refreshed'), 'info');
});
// Load scores when scores tab is shown
@@ -867,19 +892,19 @@
// Refresh scores button
document.getElementById('refresh-scores')?.addEventListener('click', async () => {
await fetchScores();
showAlert('Scores rafraîchis', 'info');
showAlert(t('picks.scoresRefreshed', 'Scores refreshed'), 'info');
});
// Initial load
window.addEventListener('DOMContentLoaded', async function() {
const pageLoader = createPageLoader({
title: 'Chargement des picks',
subtitle: 'Récupération des données…'
title: t('picks.loadingTitle', 'Loading picks'),
subtitle: t('picks.loadingSubtitle', 'Fetching data…')
});
try {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
pageLoader.setProgress(12);
await fetchCategories(pageLoader);
+32 -18
View File
@@ -7,6 +7,20 @@ function decodeJwt(token) {
return JSON.parse(atob(padded));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
let progress = 0;
@@ -262,19 +276,19 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
pendingRestoreTime = clamped;
restoreInProgress = true;
try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
lastSaveStatus = t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) });
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) }) });
} else {
pendingRestoreTime = null;
restoreInProgress = false;
lastSaveStatus = 'Début';
setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' });
lastSaveStatus = t('player.beginning', 'Start');
setProgressUi({ state: 'info', text: t('player.beginning', 'Start'), showText: true, ariaText: t('player.playFromBeginning', 'Playing from the beginning') });
}
return true;
}
function scheduleRestore() {
if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
if (!restored) setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
void tryRestoreProgress();
}
@@ -315,18 +329,18 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
} catch (_) {}
// Icon-only saving indicator (avoid noisy text updates).
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' });
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: t('player.saving', 'Saving…') });
const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, keepalive: !!keepalive });
if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user.
stopInterval();
try { localStorage.removeItem('auth_token'); } catch (_) {}
lastSaveStatus = 'Connexion requise pour sauvegarder.';
setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus });
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
lastSaveStatus = t('player.connectionRequired', 'Connection required for saving.');
setProgressUi({ state: 'warning', text: t('player.connection', 'Connection'), showText: true, ariaText: lastSaveStatus });
showAlertVariant(t('player.sessionExpired', 'Session expired. Please reconnect to continue saving progress.'), 'warning');
return;
}
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
lastSaveStatus = t('player.saved', 'Saved at {time}.', { time: formatClock(snap.time) });
setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus });
if (savedUiTimer) window.clearTimeout(savedUiTimer);
savedUiTimer = window.setTimeout(() => {
@@ -574,11 +588,11 @@ function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount,
});
const parts = [];
if (avg && count > 0) {
parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`);
} else {
parts.push('Note utilisateurs: —');
}
const ratingLabel = (avg && count > 0) ? `${avg.toFixed(1)} (${count})` : '—';
const translatedRating = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: ratingLabel })
: `User rating ${ratingLabel}`;
parts.push(translatedRating);
metaEl.textContent = parts.join(' • ');
}
@@ -1250,7 +1264,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) {
}
window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
const pageLoader = createPageLoader({ title: t('player.loadingPlayer', 'Loading player…') });
// Basic auth UX: require a valid local token (like the rest of the app).
const token = localStorage.getItem('auth_token');
@@ -1373,13 +1387,13 @@ window.onload = async function () {
const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) {
applySubtitleTrack({ videoEl, movie, token });
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
const userId = normalizeUserId(decoded);
if (userId) {
setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id });
} else {
// Avoid a "stuck" progress state if the token payload is unexpected.
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." });
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: t('player.unableToIdentifyUser', "Unable to identify user for saving progress.") });
}
} else {
setProgressUi({ state: 'info', text: '—', showText: true });
+27 -2
View File
@@ -2,6 +2,22 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
'Mauvais nom de chien': 'auth.dogAnswerIncorrect',
"L'utilisateur s'est enregistré avec succès !": 'auth.registrationSuccess',
'User registered successfully!': 'auth.registrationSuccess',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
}
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
@@ -92,7 +108,12 @@ initPage();
if (res.ok) {
const data = await res.json();
// Show success message in the modal
responseMessageElement.textContent = data.message;
responseMessageElement.textContent = translateApiMessage(
data?.messageKey,
data?.message,
'auth.registrationSuccess',
'User registered successfully!'
);
// Show the modal
modal.show();
@@ -104,7 +125,11 @@ initPage();
} else {
const error = await res.json();
// Show error message in the modal
responseMessageElement.textContent = `Error: ${error.message}`;
const prefix = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('common.errorPrefix')
: 'Error:';
const msg = translateApiMessage(error?.messageKey, error?.message, 'common.errorOccurred', 'An error occurred');
responseMessageElement.textContent = `${prefix} ${msg}`.trim();
// Show the modal
modal.show();
+23 -3
View File
@@ -7,6 +7,21 @@
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const API_ENDPOINT = '/api/users/theme';
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function normalizeTheme(value) {
return value === 'light' || value === 'dark' ? value : null;
}
@@ -111,9 +126,14 @@
buttons.forEach((button) => {
button.setAttribute('aria-checked', String(isDark));
button.dataset.theme = theme;
const label = isDark ? 'Mode sombre' : 'Mode clair';
const sourceLabel = source === 'system' ? ' (système)' : '';
button.setAttribute('title', `${label}${sourceLabel} — Shift+clic pour système`);
const label = isDark
? t('theme.darkMode', 'Dark mode')
: t('theme.lightMode', 'Light mode');
const sourceLabel = source === 'system'
? t('theme.systemSuffix', ' (system)')
: '';
const hint = t('theme.systemHint', '— Shift+click for system');
button.setAttribute('title', `${label}${sourceLabel} ${hint}`.trim());
});
}
+50 -26
View File
@@ -1,7 +1,7 @@
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('stats.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -118,10 +118,31 @@ function createPageLoader(options = {}) {
return { setProgress, setSubtitle, done, fail };
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des statistiques',
subtitle: 'Récupération des données…'
title: t('stats.loadingStats', 'Loading statistics'),
subtitle: t('stats.fetchingData', 'Fetching data…')
});
const token = localStorage.getItem('auth_token');
@@ -173,7 +194,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!list.length) {
tbody.innerHTML = `
<tr>
<td colspan="2" class="text-muted">Aucun gagnant défini.</td>
<td colspan="2" class="text-muted">${t('stats.noWinnersDefined', 'No winners defined.')}</td>
</tr>
`;
return;
@@ -187,7 +208,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const arr = byYear.get(String(y)) || [];
arr.push({
year: y,
name: w?.name || '(utilisateur supprimé)',
name: w?.name || t('stats.deletedUser', '(deleted user)'),
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
});
byYear.set(String(y), arr);
@@ -215,7 +236,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (sorted.length > 1) {
const small = document.createElement('div');
small.className = 'text-muted small';
small.textContent = 'égalité';
small.textContent = t('stats.tie', 'tie');
tdYear.appendChild(document.createElement('br'));
tdYear.appendChild(small);
}
@@ -227,7 +248,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const name = document.createElement('span');
name.className = 'fw-semibold';
name.textContent = w?.name || '(utilisateur supprimé)';
name.textContent = w?.name || t('stats.deletedUser', '(deleted user)');
row.appendChild(name);
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
@@ -254,7 +275,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!completions || typeof completions !== 'object') {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">Impossible de charger les finisseurs 100%.</td>
<td colspan="3" class="text-muted">${t('stats.unableToLoadCompleters', 'Unable to load 100% completers.')}</td>
</tr>
`;
return;
@@ -267,7 +288,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!years.length) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">Aucune année trouvée.</td>
<td colspan="3" class="text-muted">${t('stats.noYearsFound', 'No years found.')}</td>
</tr>
`;
return;
@@ -311,9 +332,9 @@ window.addEventListener('DOMContentLoaded', async function () {
let dateStr = '';
if (u?.completedAt) {
const date = new Date(u.completedAt);
dateStr = ` (${date.toLocaleDateString('fr-CA')})`;
dateStr = ` (${date.toLocaleDateString(getLocale())})`;
}
badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`;
badge.textContent = `${rankPrefix}${u?.name || t('stats.unnamed', '(unnamed)')}${dateStr}`;
wrap.appendChild(badge);
});
tdNames.appendChild(wrap);
@@ -359,7 +380,7 @@ window.addEventListener('DOMContentLoaded', async function () {
try {
pageLoader.setProgress(12);
// Modal UI (Films regardés)
// Modal UI (watched movies)
const moviesModalEl = document.getElementById('moviesModal');
const movieGridEl = document.getElementById('movie-grid');
const moviesSummaryEl = document.getElementById('movies-summary');
@@ -383,7 +404,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
try {
return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' });
return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', day: 'numeric' });
} catch (_) {
return d.toLocaleDateString();
}
@@ -413,7 +434,9 @@ window.addEventListener('DOMContentLoaded', async function () {
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';
moviesSummaryEl.textContent = total
? t('stats.filmCount', '{count} movie{plural}', { count: total, plural: total > 1 ? 's' : '' })
: t('stats.noFilms', '0 movies');
movieGridEl.innerHTML = '';
if (!sorted.length) {
@@ -452,7 +475,7 @@ window.addEventListener('DOMContentLoaded', async function () {
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}`);
item.setAttribute('aria-label', vodLink ? t('stats.openLink', 'Open link') + ': ' + title : t('stats.openPlayer', 'Open player') + ': ' + title);
}
const poster = document.createElement('img');
@@ -523,7 +546,7 @@ window.addEventListener('DOMContentLoaded', async function () {
imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`;
imdbLink.target = '_blank';
imdbLink.rel = 'noopener noreferrer';
imdbLink.setAttribute('aria-label', 'Voir sur IMDb');
imdbLink.setAttribute('aria-label', (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb');
const imdbIcon = document.createElement('span');
imdbIcon.className = 'imdb-icon';
@@ -555,7 +578,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (watchedLabel) {
const d = document.createElement('span');
d.className = 'text-muted';
d.textContent = `Vu le ${watchedLabel}`;
d.textContent = t('stats.watchedOn', 'Watched on {date}', { date: watchedLabel });
meta.appendChild(d);
}
@@ -568,7 +591,7 @@ window.addEventListener('DOMContentLoaded', async function () {
details.className = 'stats-movie-details';
const summary = document.createElement('summary');
summary.textContent = 'Détails';
summary.textContent = t('common.details', 'Details');
details.appendChild(summary);
const body = document.createElement('div');
@@ -592,8 +615,8 @@ window.addEventListener('DOMContentLoaded', async function () {
if (moviesModalTitleEl) {
const count = currentModalMovies.length;
moviesModalTitleEl.textContent = currentModalUserName
? `Films regardés — ${currentModalUserName} (${count})`
: `Films regardés (${count})`;
? t('stats.watchedMoviesUser', 'Watched movies — {user} ({count})', { user: currentModalUserName, count: count })
: t('stats.watchedMovies', 'Watched movies ({count})', { count: count });
}
renderMoviesModal();
@@ -619,10 +642,10 @@ window.addEventListener('DOMContentLoaded', async function () {
pageLoader.setProgress(20);
const activeYear = await fetchActiveYear();
if (activeYear) {
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
document.title = `Pool Oscars (${activeYear}) - ${t('stats.pageTitle', 'User Statistics')}`;
// Keep the main heading clean; show the year in the "Classement" tab instead.
const h2 = document.querySelector('h2');
if (h2) h2.textContent = 'Statistiques des utilisateurs';
if (h2) h2.textContent = t('stats.pageTitle', 'User Statistics');
const badge = document.getElementById('stats-active-year-badge');
if (badge) {
badge.textContent = String(activeYear);
@@ -656,7 +679,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const userTableBody = document.getElementById('user-table-body');
// Check visibility config for Bon picks column
let showBonPicksColumn = true;
let showBonPicksColumn = false;
try {
const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (visibilityRes.ok) {
@@ -694,7 +717,8 @@ window.addEventListener('DOMContentLoaded', async function () {
// Update Films column header with total
const filmsHeader = document.getElementById('films-header');
if (filmsHeader && totalMovies > 0) {
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
const filmsLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('stats.films') : 'Movies';
filmsHeader.innerHTML = `${filmsLabel} <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
}
stats.forEach((userStat, index) => {
@@ -751,7 +775,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) {
statsError.textContent = "Impossible de charger le classement pour le moment.";
statsError.textContent = t('stats.unableToLoadRanking', 'Unable to load the ranking right now.');
statsError.classList.remove('d-none');
}
// Still attempt to render these sections even if stats fail (best effort)
+13 -1
View File
@@ -3,8 +3,20 @@
const res = await fetch('/api/version');
if (res.ok) {
const data = await res.json();
if (window.i18n && typeof window.i18n.init === 'function') {
try {
await window.i18n.init();
} catch (_) {}
}
const hasI18n = window.i18n && typeof window.i18n.t === 'function';
let message = data.message || '';
if (data && data.configured === false) {
if (hasI18n) {
message = window.i18n.t('footer.versionNotConfigured');
}
}
// Update footer with the fetched version info
const footerText = `${data.date} - ${data.version} - ${data.message}`;
const footerText = `${data.date} - ${data.version} - ${message}`;
document.getElementById('footer-text').textContent = footerText;
} else {
document.getElementById('footer-text').textContent = '';
+40 -9
View File
@@ -41,6 +41,27 @@
let categories = [];
let currentYear = new Date().getFullYear();
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
@@ -91,7 +112,7 @@
renderPicks();
} catch (err) {
console.error('Error fetching picks:', err);
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
showAlert(t('viewPicks.errorLoading', 'Error loading picks: {error}', { error: err.message }), 'danger');
}
}
@@ -102,7 +123,7 @@
if (allPicks.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
${t('viewPicks.noPicks', 'No user has submitted picks for year {year} yet.', { year: currentYear })}
</div>
`;
return;
@@ -115,9 +136,19 @@
});
container.innerHTML = allPicks.map(pick => {
const userName = pick.userId?.name || 'Utilisateur inconnu';
const userName = pick.userId?.name || t('viewPicks.unknownUser', 'Unknown user');
const userEmail = pick.userId?.email || '';
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
let submittedDate = '—';
if (pick.submittedAt) {
const d = new Date(pick.submittedAt);
if (!Number.isNaN(d.getTime())) {
try {
submittedDate = d.toLocaleString(getLocale(), { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
submittedDate = d.toLocaleString();
}
}
}
// Create a map of picks by category
const picksMap = new Map();
@@ -141,7 +172,7 @@
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
<span class="text-muted">${selected}</span>
</div>
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
${isWinner ? `<span class="badge bg-success">${t('viewPicks.winnerBadge', 'Winner')}</span>` : ''}
</div>
</div>
`;
@@ -156,13 +187,13 @@
<small class="text-muted">${userEmail}</small>
</div>
<div class="text-end">
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
<span class="badge bg-primary">${pick.picks.length} / ${categories.length} catégories</span>
<small class="text-muted">${t('viewPicks.submittedOn', 'Submitted on: {date}', { date: submittedDate })}</small><br>
<span class="badge bg-primary">${t('viewPicks.categoriesCount', '{count} / {total} categories', { count: pick.picks.length, total: categories.length })}</span>
</div>
</div>
</div>
<div class="card-body">
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
${picksList || `<div class="text-muted">${t('viewPicks.noPicks2', 'No picks recorded')}</div>`}
</div>
</div>
`;
@@ -172,7 +203,7 @@
// Event listeners
document.getElementById('refresh-picks')?.addEventListener('click', () => {
fetchAllPicks();
showAlert('Choix rafraîchis', 'info');
showAlert(t('viewPicks.picksRefreshed', 'Picks refreshed'), 'info');
});
// Initial load
+306
View File
@@ -0,0 +1,306 @@
/**
* Lightweight i18n module for Oscar Pool
* Supports English (base) and French translations
*/
(function() {
'use strict';
const STORAGE_KEY = 'app_language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'fr'];
let currentLang = DEFAULT_LANG;
let translations = {};
let loadedLangs = new Set();
let initPromise = null;
/**
* Detect language from: localStorage → browser → default
*/
function detectLanguage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) {
return stored;
}
const browserLang = (navigator.language || navigator.userLanguage || '').split('-')[0].toLowerCase();
if (SUPPORTED_LANGS.includes(browserLang)) {
return browserLang;
}
return DEFAULT_LANG;
}
/**
* Load translation file for a language
*/
async function loadTranslations(lang) {
if (loadedLangs.has(lang)) return true;
try {
const res = await fetch(`/locales/${lang}.json`, { cache: 'default' });
if (!res.ok) {
console.warn(`i18n: Failed to load ${lang}.json (${res.status})`);
return false;
}
const data = await res.json();
translations[lang] = data;
loadedLangs.add(lang);
return true;
} catch (err) {
console.warn(`i18n: Error loading ${lang}.json`, err);
return false;
}
}
/**
* Translate a key with optional interpolation
* @param {string} key - Translation key (e.g., 'nav.logout')
* @param {Object} params - Optional parameters for interpolation
* @returns {string} - Translated string or key if not found
*/
function t(key, params = {}) {
if (!key) return '';
// Try current language, then fallback to default
let text = translations[currentLang]?.[key];
if (text === undefined && currentLang !== DEFAULT_LANG) {
text = translations[DEFAULT_LANG]?.[key];
}
if (text === undefined) {
// Return key as fallback (helps identify missing translations)
return key;
}
// Simple interpolation: {varName} → value
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
const regex = new RegExp(`\\{${k}\\}`, 'g');
text = text.replace(regex, String(params[k]));
});
}
return text;
}
/**
* Set the current language
*/
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
return true;
}
/**
* Get the current language
*/
function getLanguage() {
return currentLang;
}
/**
* Get list of supported languages
*/
function getSupportedLanguages() {
return [...SUPPORTED_LANGS];
}
/**
* Translate all elements with data-i18n attributes
*/
function translatePage() {
// Translate text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
// Check for interpolation params
const paramsAttr = el.getAttribute('data-i18n-params');
let params = {};
if (paramsAttr) {
try {
params = JSON.parse(paramsAttr);
} catch (_) {
// Ignore invalid JSON
}
}
const translated = t(key, params);
if (translated !== key) {
el.textContent = translated;
}
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.placeholder = translated;
}
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.setAttribute('aria-label', translated);
}
});
// Translate titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.title = translated;
}
});
// Translate option values in selects
document.querySelectorAll('option[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.textContent = translated;
}
});
// Mark body as ready (for CSS transitions)
document.body.classList.add('i18n-ready');
}
/**
* Update a single element's translation (for dynamic content)
*/
function translateElement(el, key, params = {}) {
if (!el || !key) return;
el.textContent = t(key, params);
}
/**
* Fetch language setting from server (optional, for admin-set language)
*/
async function fetchServerLanguage() {
try {
const res = await fetch('/api/settings/language', {
method: 'GET',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.language && SUPPORTED_LANGS.includes(data.language)) {
return data.language;
}
}
} catch (_) {
// Silently fail - use local detection
}
return null;
}
/**
* Initialize i18n system
* @param {string} forceLang - Optional language to force (bypasses detection)
*/
async function init(forceLang = null) {
// Prevent multiple concurrent initializations
if (initPromise) return initPromise;
initPromise = (async () => {
// Determine language to use
let lang = forceLang;
if (!lang) {
// Try server setting first (if user is logged in and admin set a language)
const serverLang = await fetchServerLanguage();
if (serverLang) {
lang = serverLang;
} else {
lang = detectLanguage();
}
}
// Always load default language as fallback
await loadTranslations(DEFAULT_LANG);
// Load current language if different
if (lang !== DEFAULT_LANG) {
const loaded = await loadTranslations(lang);
if (!loaded) {
lang = DEFAULT_LANG;
}
}
setLanguage(lang);
translatePage();
return lang;
})();
return initPromise;
}
/**
* Change language and re-translate page
*/
async function changeLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
// Load translations if not already loaded
if (!loadedLangs.has(lang)) {
const loaded = await loadTranslations(lang);
if (!loaded) return false;
}
setLanguage(lang);
translatePage();
return true;
}
/**
* Check if translations are loaded
*/
function isReady() {
return loadedLangs.size > 0;
}
// Expose globally
window.i18n = {
t,
init,
setLanguage,
getLanguage,
changeLanguage,
getSupportedLanguages,
translatePage,
translateElement,
loadTranslations,
fetchServerLanguage,
isReady,
DEFAULT_LANG,
SUPPORTED_LANGS
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
// DOM already loaded
init();
}
})();
+584
View File
@@ -0,0 +1,584 @@
{
"nav.films": "Movies",
"nav.checklist": "Checklist",
"nav.stats": "Stats",
"nav.myPicks": "My picks",
"nav.admin": "Admin",
"nav.logout": "Log out",
"nav.viewAllPicks": "View All Picks",
"common.loading": "Loading…",
"common.preparingPage": "Preparing page…",
"common.errorLoading": "Error loading.",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.confirm": "Confirm",
"common.refresh": "Refresh",
"common.close": "Close",
"common.copy": "Copy",
"common.reset": "Reset",
"common.add": "Add",
"common.edit": "Edit",
"common.remove": "Remove",
"common.create": "Create",
"common.actions": "Actions",
"common.yes": "Yes",
"common.no": "No",
"common.all": "All",
"common.none": "None",
"common.search": "Search",
"common.filter": "Filter",
"common.year": "Year",
"common.name": "Name",
"common.email": "Email",
"common.status": "Status",
"common.date": "Date",
"common.title": "Title",
"common.description": "Description",
"common.networkError": "Network error",
"common.errorOccurred": "An error occurred",
"common.errorPrefix": "Error:",
"common.saving": "Saving...",
"common.success": "Success",
"common.toggleDarkMode": "Toggle dark mode",
"common.details": "Details",
"common.oscarPool": "Oscar Pool",
"auth.login": "Login",
"auth.register": "Register",
"auth.email": "Email",
"auth.password": "Password",
"auth.name": "Name",
"auth.noAccount": "No account?",
"auth.hasAccount": "Already have an account?",
"auth.signUp": "Sign up",
"auth.signIn": "Sign in",
"auth.forgotPassword": "Forgot password?",
"auth.emailPlaceholder": "[email protected]",
"auth.namePlaceholder": "Your name",
"auth.passwordMin": "Minimum {min} characters",
"auth.passwordsDoNotMatch": "Passwords do not match",
"auth.confirmPassword": "Confirm password",
"auth.createAdminAccount": "Create admin account",
"auth.firstTimeSetup": "First time setup",
"auth.noAdminExists": "No administrator exists. Create the first admin account to get started.",
"auth.registrationStatus": "Registration status",
"auth.returnToHome": "Return to home page",
"auth.setupRequired": "Setup required",
"auth.appMustBeConfigured": "The application must first be configured by an administrator.",
"auth.goToSetup": "Go to setup page",
"auth.dogQuestion": "What is Cath & Nath's dog's name?",
"auth.errorCreatingAccount": "Error creating account",
"auth.networkErrorRetry": "Network error. Please try again.",
"auth.userNotFound": "User not found",
"auth.dogAnswerIncorrect": "Incorrect dog name.",
"auth.registrationSuccess": "User registered successfully!",
"auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.",
"auth.passwordMinLength": "Password must be at least 8 characters.",
"auth.loginFailed": "Login failed",
"auth.invalidCredentials": "Invalid credentials",
"auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.",
"changePassword.title": "Change Password",
"changePassword.tempPassword": "Your account uses a temporary password. Please set a new password to continue.",
"changePassword.newPassword": "New password",
"changePassword.minChars": "Minimum 8 characters.",
"changePassword.passwordTooShort": "Password must be at least 8 characters.",
"movies.title": "Movies",
"movies.lastUpdated": "Last movie update:",
"movies.watchedRatio": "Watched: {watched} / {total} ({percent}%)",
"movies.timeUntilOscars": "Time until the Oscars ({date} {year}) — {timeLeft}",
"movies.timeUntilOscarsLabel": "Time until the Oscars",
"movies.daysLeft": "{days} days",
"movies.hoursLeft": "{hours} hours",
"movies.unwatchedOnly": "Unwatched movies only",
"movies.clickToPlay": "Click the poster or title to open the player.",
"movies.watched": "WATCHED",
"movies.noSource": "No source",
"movies.nominatedCategories": "Nominated categories",
"movies.resumeAt": "Resume at {time}",
"movies.userRating": "User rating {rating}",
"movies.errorLoading": "Error loading.",
"movies.errorLoadingList": "Unable to load movie list. Try again in a moment.",
"movies.errorLoading": "Unable to load movies. Check your connection and try again.",
"movies.unableToLoadMovies": "Unable to load movies. Please try again later.",
"movies.unableToLoadMoviesNetwork": "Unable to load movies. Check your connection and try again.",
"movies.loadingMovies": "Loading movies",
"movies.fetchingData": "Fetching data…",
"movies.viewOnImdb": "View on IMDb",
"checklist.title": "Checklist",
"checklist.seen": "Seen",
"checklist.filmTitle": "Title",
"checklist.date": "Date",
"checklist.watchedOn": "Watched on:",
"checklist.confirmRemoval": "Confirm removal",
"checklist.removeConfirmText": "Are you sure you want to remove {title} from your list?",
"checklist.moviesToWatchPerDay": "Movies to watch per day: {count} films",
"checklist.congratulations": "Congratulations! very nice 🎉🎉🎉",
"checklist.browserNoVideo": "Your browser does not support video.",
"checklist.loadingChecklist": "Loading checklist",
"checklist.errorLoading": "Error loading.",
"checklist.unableToLoad": "Unable to load checklist. Please try again later.",
"checklist.unableToLoadNetwork": "Unable to load checklist. Check your connection and try again.",
"checklist.march15": "March 15",
"checklist.loadingMovies": "Loading movies…",
"picks.title": "My picks",
"picks.myPicks": "My picks",
"picks.pageTitleWithYear": "Oscar Pool ({year}) - My picks",
"picks.pageTitleChoicesWithYear": "Oscar Pool ({year}) - My picks",
"picks.scores": "Scores",
"picks.winners": "Winners",
"picks.adminBadge": "Admin",
"picks.year": "Year:",
"picks.progress": "Progress:",
"picks.reload": "Reload",
"picks.loadingCategories": "Loading categories…",
"picks.scoresCalculatedAfterWinners": "Scores will be calculated after marking winners.",
"picks.winnersMarked": "winners marked",
"picks.noCategory": "No category",
"picks.noChoice": "No choice",
"picks.category": "Category",
"picks.choice": "Choice",
"picks.nominees": "Nominees",
"picks.noCategoriesFound": "No categories found for year {year}",
"picks.adminMustImportCategories": "An administrator must first import the categories.",
"picks.selectionHeader": "Selection",
"picks.winnerHeader": "Winner",
"picks.noCategoriesAvailable": "No categories available",
"picks.winnerMarkedSuccess": "Winner marked successfully",
"picks.winnerRemoved": "Winner removed",
"picks.noWinnerMarked": "No winner marked. Scores will be calculated once winners are set.",
"picks.scoresCalculated": "Scores calculated! {count} user(s) evaluated.",
"picks.scoresRefreshed": "Scores refreshed",
"picks.calculatingScores": "Calculating scores…",
"picks.calculateScores": "Calculate scores",
"picks.dataRefreshed": "Data refreshed",
"picks.noScoresAvailable": "No scores available. Users must first submit their picks.",
"picks.details": "Details",
"picks.choicesOf": "Choices of {name}",
"picks.noDetailsAvailable": "No details available.",
"picks.summary": "Summary",
"picks.correct": "{count} correct",
"picks.incorrect": "{count} incorrect",
"picks.correctPicks": "Correct ({count})",
"picks.errorLoadingCategories": "Error loading categories: {error}",
"picks.preparingPage": "Preparing page…",
"picks.errorAutoSaving": "Error auto-saving: {error}",
"picks.incompleteSelection": "You have selected only {selected} out of {total} categories. Do you want to save anyway?",
"picks.successSaved": "Your picks have been saved successfully!",
"picks.errorSubmitting": "Error submitting picks: {error}",
"picks.reloaded": "Picks reloaded",
"picks.categoryHeader": "Category",
"picks.userHeader": "User",
"picks.winnerMarked": "Winner marked successfully",
"picks.winnerDeleted": "Winner deleted",
"picks.errorUpdatingWinner": "Error: {error}",
"picks.noWinnersMarked": "No winners marked. Scores will be calculated once winners are defined.",
"picks.scoresCalculated": "Scores calculated successfully! {count} user(s) evaluated.",
"picks.errorCalculatingScores": "Error: {error}",
"picks.errorLoadingScores": "Error loading scores.",
"picks.incorrectPicks": "Incorrect ({count})",
"picks.detailsButton": "Details",
"picks.userChoicesTitle": "Picks for {user}",
"picks.noDetailsAvailable": "No details available.",
"picks.summaryTitle": "Summary",
"picks.correctCountLabel": "correct",
"picks.incorrectCountLabel": "incorrect",
"picks.totalCountLabel": "total",
"picks.correctSectionTitle": "Correct ({count})",
"picks.incorrectSectionTitle": "Incorrect ({count})",
"picks.choiceLabel": "Choice",
"picks.winnerLabel": "Winner",
"picks.noWinnerMarkedShort": "No winner marked",
"picks.noChoicesToShow": "No picks to show.",
"picks.loadingTitle": "Loading picks",
"picks.loadingSubtitle": "Fetching data…",
"picks.yourChoice": "Choice: {choice}",
"picks.winner": "Winner: {winner}",
"picks.noWinnerMarkedYet": "No winner marked",
"picks.noPicksToShow": "No picks to show.",
"picks.partialSaveConfirm": "You have only selected {selected} out of {total} categories. Do you want to save anyway?",
"picks.savedSuccessfully": "Your picks have been saved successfully!",
"picks.errorSaving": "Error saving: {error}",
"picks.errorLoadingCategories": "Error loading categories: {error}",
"picks.autoSaveError": "Auto-save error: {error}",
"picks.picksReloaded": "Picks reloaded",
"picks.choiceDetails": "Choice details",
"picks.loadingPicks": "Loading picks",
"viewPicks.title": "View All Picks",
"viewPicks.overview": "Overview of all user picks",
"viewPicks.usersSubmitted": "{count} user(s) have submitted their picks",
"viewPicks.loadingPicks": "Loading picks…",
"viewPicks.noPicks": "No user has submitted picks for year {year} yet.",
"viewPicks.picksRefreshed": "Picks refreshed",
"viewPicks.errorLoading": "Error loading picks: {error}",
"viewPicks.unknownUser": "Unknown user",
"viewPicks.categoriesCount": "{count} / {total} categories",
"viewPicks.noPicks2": "No picks recorded",
"viewPicks.submittedOn": "Submitted on: {date}",
"viewPicks.winnerBadge": "Winner",
"player.title": "Player",
"player.loading": "Loading…",
"player.quality": "Quality",
"player.high": "High",
"player.low": "Low",
"player.rateThisFilm": "Rate this film",
"player.star": "{count} star",
"player.stars": "{count} stars",
"player.source": "Source",
"player.progress": "Progress",
"player.noVodLink": "No VOD link configured for this movie.",
"player.missingMovieId": "Missing movie id.",
"player.noPlayableSource": "No playable source configured for this movie.",
"player.beginning": "Start",
"player.resuming": "Resuming",
"player.resumingAt": "Resuming at {time}",
"player.preparingResume": "Preparing resume…",
"player.connectionRequired": "Connection required for saving.",
"player.connection": "Connection",
"player.playFromBeginning": "Playing from the beginning",
"player.sessionExpired": "Session expired. Please reconnect to continue saving progress.",
"player.saving": "Saving…",
"player.saved": "Saved at {time}.",
"player.loadingPlayer": "Loading player…",
"player.unableToIdentifyUser": "Unable to identify user for saving progress.",
"common.continueQuestion": "Continue?",
"stats.title": "Statistics",
"stats.pageTitle": "User Statistics",
"stats.ranking": "Ranking",
"stats.winners": "Winners 🏆",
"stats.completers": "100%",
"stats.rank": "#",
"stats.name": "Name",
"stats.films": "Movies",
"stats.correctPicks": "Correct picks",
"stats.points": "Points",
"stats.year": "Year",
"stats.winnersColumn": "Winner(s)",
"stats.completersHelp": "Users who have watched 100% (by year)",
"stats.noWinnersDefined": "No winners defined.",
"stats.noYearsFound": "No years found.",
"stats.unableToLoadRanking": "Unable to load the ranking right now.",
"stats.totalFilms": "Total movies",
"stats.names": "Names",
"stats.moviesWatched": "Movies watched",
"stats.noMoviesToShow": "No movies to show.",
"stats.loading": "Loading…",
"stats.preparingPage": "Preparing page…",
"stats.loadingStats": "Loading statistics",
"stats.fetchingData": "Fetching data…",
"stats.deletedUser": "(deleted user)",
"stats.tie": "tie",
"stats.unableToLoadCompleters": "Unable to load 100% completers.",
"stats.unnamed": "(unnamed)",
"stats.openLink": "Open link",
"stats.openPlayer": "Open player",
"stats.watchedOn": "Watched on {date}",
"stats.watchedMoviesUser": "Watched movies — {user} ({count})",
"stats.watchedMovies": "Watched movies ({count})",
"stats.filmCount": "{count} movie{plural}",
"stats.noFilms": "0 movies",
"admin.movies.noMovies": "No movies.",
"admin.title": "Oscar Pool - Admin",
"admin.tabs.settings": "Settings",
"admin.tabs.users": "Users",
"admin.tabs.modal100": "Modal 100%",
"admin.tabs.winners": "Winners",
"admin.tabs.oscarCategories": "Oscar Categories",
"admin.tabs.backupRestore": "Backup / Restore",
"admin.tabs.version": "Version",
"admin.settings.info": "Global site settings (year, dates, player options).",
"admin.settings.oscarDate": "Oscar Date",
"admin.settings.visibility": "Visibility",
"admin.settings.language": "Language",
"admin.settings.oscarDateDesc": "Configure the ceremony date for the countdown (Films & Checklist). Default: March 15.",
"admin.settings.oscarDateDefaultShort": "— (default: 03-15)",
"admin.settings.year": "Year",
"admin.settings.applyActiveYear": "Apply active year",
"admin.settings.yearBasedOnMovies": "Based on years present in movies.",
"admin.settings.date": "Date",
"admin.settings.dateFormat": "Format: YYYY-MM-DD.",
"admin.settings.configuredDates": "Configured dates",
"admin.settings.pointsDesc": "Configure points awarded for watched movies and correct Oscar picks.",
"admin.settings.pointsPerMovie": "Points per movie watched",
"admin.settings.pointsPerMovieDesc": "Points awarded for each completed movie.",
"admin.settings.pointsPerCorrectPick": "Points per correct pick",
"admin.settings.pointsPerCorrectPickDesc": "Points awarded for each correct Oscar pick.",
"admin.settings.reload": "Reload",
"admin.settings.pointsCalculation": "Points calculation",
"admin.settings.total": "Total",
"admin.settings.moviesWatched": "Movies watched",
"admin.settings.correctPicks": "Correct picks",
"admin.settings.pointsSaved": "Configuration saved: {p} pts/movie, {q} pts/correct pick",
"admin.settings.noYearAddMovie": "No year (add a movie)",
"admin.settings.defaultBadge": "Default",
"admin.settings.pointsMustBeNonNegative": "Points must be zero or positive",
"admin.visibility.showPicksFeatures": "Show \"Picks\" features",
"admin.visibility.showPicksFeaturesDesc": "Controls display of \"My picks\" button in navigation and \"Correct picks\" column in statistics.",
"admin.visibility.showSourceProgress": "Show \"Source\" + \"Progress\" (admins)",
"admin.visibility.showSourceProgressDesc": "Controls display of debug block in player.html (admins only).",
"admin.visibility.showVersionTab": "Show \"Version\" tab",
"admin.visibility.showVersionTabDesc": "Controls display of the \"Version\" tab in the admin panel.",
"admin.visibility.saved": "Configuration saved",
"admin.visibility.saveError": "Error saving configuration: {error}",
"admin.language.siteLanguage": "Site language",
"admin.language.siteLanguageDesc": "Changes the language for all users. Page refresh required.",
"admin.version.info": "Controls the version displayed in the footer (stored in DB). Click ✓ to activate a version, then check the preview.",
"admin.version.createNew": "Create a new version",
"admin.version.createNewDesc": "Adds an entry to the history (you can activate it immediately).",
"admin.version.version": "Version",
"admin.version.versionPlaceholder": "e.g.: 1.0.1",
"admin.version.versionExample": "E.g.: 1.2.3",
"admin.version.messageOptional": "Message (optional)",
"admin.version.messagePlaceholder": "e.g.: Hotfix login",
"admin.version.messageDesc": "Short & readable (shown in footer).",
"admin.version.create": "Create",
"admin.version.createAndActivate": "Create & activate",
"admin.version.created": "Version created.",
"admin.version.createdAndActivated": "Version created & activated.",
"admin.version.confirmDelete": "Delete this version?",
"admin.backup.chooseFile": "Choose a backup file (.ndjson.gz).",
"admin.backup.restoreWithDrop": "RESTORE with DROP: all restored collections will be replaced.",
"admin.backup.restoreWithoutDrop": "RESTORE without DROP: high risk of duplicates/errors if the DB already contains data.",
"admin.modal100.info": "Content displayed when a user has watched 100% of films.",
"admin.modal100.content": "Content",
"admin.modal100.contentDesc": "Edit the content then save. Preview is live (not saved until you save).",
"admin.modal100.titleLabel": "Title",
"admin.modal100.titlePlaceholder": "Congratulations! ...",
"admin.modal100.textOptional": "Text (optional)",
"admin.modal100.textPlaceholder": "Text displayed in the modal (line breaks preserved)",
"admin.modal100.textDesc": "Displayed as plain text (safe). Leave empty if not needed.",
"admin.modal100.htmlOptional": "HTML (optional)",
"admin.modal100.htmlDesc": "Displayed as HTML (server removes script tags and on* handlers).",
"admin.modal100.videoSrc": "Video src (optional)",
"admin.modal100.videoSrcDesc": "Relative path (e.g.: video/reward.mp4) or full URL. Empty = no video.",
"admin.modal100.browserNoVideo": "Your browser does not support video.",
"admin.modal100.textLabel": "Text",
"admin.modal100.textInfoShort": "displayed as-is (safe), line breaks preserved.",
"admin.modal100.htmlInfoShort": "server removes script tags and on* attributes.",
"admin.modal100.videoLabel": "Video",
"admin.modal100.videoInfoShort": "accepts relative path (e.g.: video/reward.mp4) or full URL.",
"admin.users.usersCount": "user(s)",
"admin.users.addUser": "Add user",
"admin.users.name": "Name",
"admin.users.status": "Status",
"admin.users.actions": "Actions",
"admin.users.loading": "Loading list…",
"admin.users.resetPassword": "Reset password",
"admin.users.resetPasswordDesc": "Generates a temporary password. The user will need to change it on next login.",
"admin.users.tempPasswordOnce": "Temporary password (shown once)",
"admin.users.generate": "Generate",
"admin.users.deleteUser": "Delete user",
"admin.users.aboutToDelete": "You are about to delete:",
"admin.users.actionIrreversible": "This action is irreversible.",
"admin.users.typeEmailToConfirm": "To confirm, type the exact email:",
"admin.users.emailPlaceholder": "[email protected]",
"admin.users.deleteButtonActivates": "The \"Delete\" button will activate only if the email matches.",
"admin.users.addUserDesc": "Create an account with a temporary password (shown once). The user will need to change it on next login.",
"admin.users.namePlaceholder": "First name / nickname",
"admin.users.reset": "Reset",
"admin.users.cannotDeleteSelf": "You cannot delete your own account from here.",
"admin.users.adminDeleteDisabled": "Deleting admin accounts is disabled via this interface.",
"admin.users.creating": "Creating…",
"admin.users.nameRequired": "Name required.",
"admin.users.emailRequired": "Email required.",
"admin.users.userCreated": "User created. Temporary password generated.",
"admin.users.tempPasswordExpires": "Expires: {expiresAt}",
"admin.users.tempPasswordGenerating": "Generating temporary password…",
"admin.users.tempPasswordGenerated": "Temporary password generated. Copy it and send it to the user.",
"admin.users.tempPasswordGeneratedOnReset": "Temporary password generated. The user must change it on next login.",
"admin.users.cannotCopyAuto": "Unable to copy automatically. Copy manually.",
"admin.users.copied": "Copied.",
"admin.users.userDeleted": "User deleted.",
"admin.movies.idCopied": "Movie ID copied.",
"admin.movies.addedWithSubtitles": "Movie added with subtitles.",
"admin.movies.addedSuccessfully": "Movie added successfully.",
"admin.movies.addedNoSubtitles": "Movie added, but subtitles were not uploaded: {error}",
"admin.movies.updatedSuccessfully": "Movie updated successfully.",
"admin.movies.updatedNoSubtitles": "Movie updated, but subtitles were not uploaded: {error}",
"admin.language.saved": "Language saved",
"admin.movies.addInfo": "Add movies: this section auto-fetches movie info via imdb_id (OMDb).",
"admin.movies.oscarYear": "Oscar year",
"admin.movies.oscarYearDesc": "Separates movies & stats by year.",
"admin.movies.category": "Category",
"admin.movies.allYears": "All",
"admin.movies.refreshOmdb": "Refresh OMDb",
"admin.movies.selectAll": "Select all",
"admin.movies.selected": "selected",
"admin.movies.sel": "Sel.",
"admin.movies.titleCol": "Title",
"admin.movies.player": "Player",
"admin.movies.year": "Year",
"admin.movies.editMovie": "Edit movie",
"admin.movies.info": "Info",
"admin.movies.playerVideo": "Player / Video",
"admin.movies.imdbIdLabel": "IMDB ID (e.g.: tt1234567)",
"admin.movies.rating": "Rating (imdbRating)",
"admin.movies.runtime": "Runtime",
"admin.movies.runtimeExample": "E.g.: 142 min",
"admin.movies.camTag": "CAM tag",
"admin.movies.refreshFromOmdb": "Refresh from OMDb (overwrites title / plot / rating / runtime / poster)",
"admin.movies.refreshFromOmdbDesc": "If checked, the server makes a new OMDb call via IMDB ID when saving.",
"admin.movies.posterUrl": "Poster URL",
"admin.movies.description": "Description (Plot)",
"admin.movies.playerSource": "Player (source)",
"admin.movies.mode": "Mode",
"admin.movies.modeDesc": "Auto = video_src → embed_src → vod_link.",
"admin.movies.videoUrl": "Video URL (mp4/webm/m3u8)",
"admin.movies.videoUrlDesc": "If you use a server file (below) and leave this empty, it will auto-use /api/video/<movieId>.",
"admin.movies.serverVideoFile": "Server video file (optional)",
"admin.movies.serverVideoFileDesc": "Relative path under VIDEO_FILES_DIR (default: public/video).",
"admin.movies.serverVideoFileLow": "Server video file (low quality, optional)",
"admin.movies.serverVideoFileLowDesc": "Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.",
"admin.movies.subtitles": "Subtitles (SRT, optional)",
"admin.movies.existingSubtitles": "Existing subtitles:",
"admin.movies.subtitlesNote": "Add one file per language; repeat upload for multiple subtitles.",
"admin.movies.removeAllSubtitles": "Remove all subtitles",
"admin.movies.removeAllSubtitlesDesc": "Deletes VTT/SRT files and resets language/label fields.",
"admin.movies.confirmRemoveSubtitle": "Delete this subtitle?",
"admin.movies.confirmDeleteSelected": "Delete {count} movie(s)? This action is irreversible.",
"admin.movies.subtitleLang": "Language (e.g.: fr, en)",
"admin.movies.subtitleLabel": "Label (displayed)",
"admin.movies.subtitleDefault": "Default",
"admin.movies.embedUrl": "Embed URL or iframe code",
"admin.movies.legacyVodLink": "Legacy VOD link (optional)",
"admin.movies.confirmRefreshOmdb": "Refresh {count} movie(s){yearLabel} from OMDb? This operation may overwrite info (title, plot, rating, poster, runtime).",
"admin.backup.info": "MongoDB database backup and restore.",
"admin.backup.backup": "Backup",
"admin.backup.restore": "Restore",
"admin.backup.help": "Help",
"admin.backup.backups": "Backups",
"admin.backup.backupsDesc": "Format .ndjson.gz, stored on server.",
"admin.backup.createBackup": "Create backup",
"admin.backup.file": "File",
"admin.backup.size": "Size",
"admin.backup.modified": "Modified",
"admin.backup.openTabToLoad": "Open tab to load list…",
"admin.backup.restoreBackup": "Restore a backup",
"admin.backup.restoreDesc": "Restore a .ndjson.gz file. The drop option is recommended.",
"admin.backup.restoreWarning": "Restore can overwrite data. Make a backup first if needed.",
"admin.backup.backupFile": "Backup file",
"admin.backup.dropBeforeRestore": "Drop collections before restore (recommended)",
"admin.backup.tip": "Tip",
"admin.backup.tipDesc": "Use drop unless you know exactly what you're doing (otherwise risk of duplicates/errors).",
"admin.backup.howItWorks": "How it works",
"admin.backup.backupHowDesc": "creates a .ndjson.gz file (MongoDB export) and keeps it on the server.",
"admin.backup.download": "Download",
"admin.backup.downloadDesc": "retrieves the file locally (useful before a risky operation).",
"admin.backup.restoreHowDesc": "imports the file. With drop, restored collections are deleted first.",
"admin.backup.quickCheck": "Quick check",
"admin.backup.quickCheckDesc": "After restore, reload the page and check \"Films\", \"Checklist\" and \"Stats\".",
"admin.backup.confirmDelete": "Delete backup \"{name}\"?",
"admin.backup.creating": "Creating…",
"admin.backup.created": "Backup created: {name}",
"admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).",
"admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).",
"admin.categories.info": "Create and manage categories manually.",
"admin.categories.manualCreation": "Manual creation",
"admin.categories.categoryCreated": "Category created successfully.",
"admin.categories.list": "List",
"admin.categories.createManually": "Create a category manually",
"admin.categories.createManuallyDesc": "Add a category and its nominees one by one.",
"admin.categories.categoryNumber": "Category number",
"admin.categories.categoryName": "Category name",
"admin.categories.nomineesOnePerLine": "Nominees (one per line)",
"admin.categories.nomineesDesc": "Enter one nominee per line.",
"admin.categories.note": "Note",
"admin.categories.noteDesc": "You can create multiple categories for the same year. The category number must be unique per year.",
"admin.categories.importedCategories": "Imported categories",
"admin.categories.manageExisting": "Manage existing categories",
"admin.categories.allYears": "All years",
"admin.categories.categoriesSelected": "category(ies) selected",
"admin.categories.editCategory": "Edit category",
"admin.categories.selectAllTitle": "Select all",
"admin.categories.table.number": "#",
"admin.categories.table.name": "Name",
"admin.categories.table.nominees": "Nominees",
"admin.categories.table.actions": "Actions",
"admin.categories.confirmDelete": "Are you sure you want to delete this category?",
"admin.categories.confirmDeleteMultiple": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.",
"admin.categories.noneFound": "No categories found.",
"admin.categories.noneFoundYear": "No categories found for year {year}.",
"admin.categories.fillAllFields": "Please fill in all fields",
"admin.categories.enterAtLeastOneNominee": "Please enter at least one nominee",
"admin.categories.numberBetween1And50": "Category number must be between 1 and 50",
"admin.categories.bulkDeleteConfirm": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.",
"admin.winners.info": "Define one or more winners per year (tie possible).",
"admin.winners.manage": "Manage",
"admin.winners.addWinner": "Add a winner",
"admin.winners.addWinnerDesc": "You can add multiple winners for the same year (tie).",
"admin.winners.user": "User",
"admin.winners.removeNote": "To remove: use the list below.",
"admin.winners.pointsOptional": "Points (optional)",
"admin.winners.pointsPlaceholder": "e.g.: 42",
"admin.winners.pointsDesc": "Leave empty if not applicable.",
"admin.winners.deleteAll": "Delete all",
"admin.winners.currentWinners": "Current winners",
"admin.winners.globalView": "Global view (all years).",
"admin.winners.howItWorks": "How it works",
"admin.winners.multipleWinners": "A year can have multiple winners (tie).",
"admin.winners.tieLabel": "(tie)",
"admin.winners.tie": "(tie)",
"admin.winners.chooseUser": "Choose a user.",
"admin.winners.confirmDeleteAll": "Delete all winners for {year}?",
"admin.player.errorLoading": "Unable to load player settings.",
"admin.versions.updated": "Version updated.",
"admin.versions.deleted": "Version deleted.",
"admin.winners.pointsOptionalInfo": "Points are optional (display only).",
"admin.winners.removeInfo": "To remove a winner, use the \"Remove\" button in the \"Current winners\" list.",
"admin.winners.tipDesc": "If you change the site's active year, also check the winners for that year.",
"admin.winners.removedFor": "Winner removed for {year}.",
"admin.winners.reloaded": "Winners reloaded.",
"admin.winners.deletedFor": "Winner deleted for {year}.",
"admin.winners.addedFor": "Winner added for {year}.",
"admin.backup.deleted": "Backup deleted.",
"admin.backup.restoreComplete": "Restore complete.",
"admin.movies.deletedSuccessfully": "Movie(s) deleted successfully.",
"admin.movies.updatedFromOmdb": "{count} movie(s) updated from OMDb.",
"admin.movies.omdbResults": "{success} success, {errors} error(s). {details}",
"admin.movies.subtitleDeleted": "Subtitle deleted.",
"admin.modal100.saved": "100% modal saved.",
"admin.content.reloaded": "Content reloaded.",
"admin.oscars.invalidYear": "Invalid year. Expected: 2026",
"admin.oscars.dateSaved": "Oscars date saved for {year}: {date}",
"admin.oscars.dateCleared": "Oscars date cleared for {year} (reverted to default: March 15).",
"admin.player.settingsSaved": "Player settings saved.",
"admin.player.settingsReloaded": "Player settings reloaded.",
"admin.activeYear.applied": "Active year applied: {year}",
"admin.configuration.reloaded": "Configuration reloaded.",
"admin.categories.categoryNotFound": "Category not found.",
"admin.categories.categoryDeleted": "Category deleted.",
"admin.categories.categoryUpdated": "Category updated successfully.",
"admin.categories.deletedCount": "{count} category/categories deleted{errors}",
"admin.oscars.invalidYearShort": "Invalid year.",
"admin.movies.subtitleDeleted": "Subtitle deleted.",
"admin.categories.categoryInvalid": "Invalid category.",
"footer.oscarPool": "Oscar Pool",
"footer.by": "by",
"footer.versionNotConfigured": "Now in full HD",
"footer.versionUnavailable": "Version unavailable (DB)."
}
+586
View File
@@ -0,0 +1,586 @@
{
"nav.films": "Films",
"nav.checklist": "Checklist",
"nav.stats": "Stats",
"nav.myPicks": "Mes picks",
"nav.admin": "Admin",
"nav.logout": "Déconnexion",
"nav.viewAllPicks": "Voir Tous les Choix",
"common.loading": "Chargement…",
"common.preparingPage": "Préparation de la page…",
"common.errorLoading": "Erreur lors du chargement.",
"common.save": "Enregistrer",
"common.cancel": "Annuler",
"common.delete": "Supprimer",
"common.confirm": "Confirmer",
"common.refresh": "Rafraîchir",
"common.close": "Fermer",
"common.copy": "Copier",
"common.reset": "Reset",
"common.add": "Ajouter",
"common.edit": "Modifier",
"common.remove": "Retirer",
"common.create": "Créer",
"common.actions": "Actions",
"common.yes": "Oui",
"common.no": "Non",
"common.all": "Tous",
"common.none": "Aucun",
"common.search": "Rechercher",
"common.filter": "Filtrer",
"common.year": "Année",
"common.name": "Nom",
"common.email": "Courriel",
"common.status": "Statut",
"common.date": "Date",
"common.title": "Titre",
"common.description": "Description",
"common.networkError": "Erreur réseau",
"common.errorOccurred": "Une erreur est survenue",
"common.errorPrefix": "Erreur :",
"common.saving": "Enregistrement...",
"common.success": "Succès",
"common.toggleDarkMode": "Basculer le mode sombre",
"common.details": "Détails",
"common.oscarPool": "Pool Oscars",
"auth.login": "Connexion",
"auth.register": "Enregistrer",
"auth.email": "Courriel",
"auth.password": "Mot de passe",
"auth.name": "Nom",
"auth.noAccount": "Pas de compte?",
"auth.hasAccount": "Déjà un compte?",
"auth.signUp": "Enregistrez-vous",
"auth.signIn": "Connectez-vous",
"auth.forgotPassword": "Mot de passe oublié?",
"auth.emailPlaceholder": "[email protected]",
"auth.namePlaceholder": "Votre nom",
"auth.passwordMin": "Minimum {min} caractères",
"auth.passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"auth.confirmPassword": "Confirmer le mot de passe",
"auth.createAdminAccount": "Créer le compte admin",
"auth.firstTimeSetup": "Première installation",
"auth.noAdminExists": "Aucun administrateur n'existe. Créez le premier compte admin pour commencer.",
"auth.registrationStatus": "Statut de l'enregistrement",
"auth.returnToHome": "Retourner à la page d'accueil",
"auth.setupRequired": "Configuration requise",
"auth.appMustBeConfigured": "L'application doit d'abord être configurée par un administrateur.",
"auth.goToSetup": "Aller à la page de configuration",
"auth.dogQuestion": "Le nom du chien à Cath & Nath?",
"auth.errorCreatingAccount": "Erreur lors de la création du compte",
"auth.networkErrorRetry": "Erreur réseau. Veuillez réessayer.",
"auth.userNotFound": "L'utilisateur n'a pas été trouvé",
"auth.dogAnswerIncorrect": "Mauvais nom de chien.",
"auth.registrationSuccess": "L'utilisateur s'est enregistré avec succès !",
"auth.loginFailed": "Échec de connexion",
"auth.invalidCredentials": "Informations d'identification non valides",
"auth.tempPasswordExpired": "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
"auth.passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères.",
"changePassword.title": "Changer le mot de passe",
"changePassword.tempPassword": "Votre compte utilise un mot de passe temporaire. Veuillez définir un nouveau mot de passe pour continuer.",
"changePassword.newPassword": "Nouveau mot de passe",
"changePassword.minChars": "Minimum 8 caractères.",
"changePassword.passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères.",
"movies.title": "Films",
"movies.lastUpdated": "Dernière mise à jour des films:",
"movies.watchedRatio": "Vu: {watched} / {total} ({percent}%)",
"movies.timeUntilOscars": "Temps avant les Oscars ({date} {year}) — {timeLeft}",
"movies.timeUntilOscarsLabel": "Temps avant les Oscars",
"movies.daysLeft": "{days} jours",
"movies.hoursLeft": "{hours} heures",
"movies.unwatchedOnly": "Films non regardés seulement",
"movies.clickToPlay": "Clique sur l'affiche ou le titre pour ouvrir le lecteur.",
"movies.watched": "VISIONNÉ",
"movies.noSource": "Aucune source",
"movies.nominatedCategories": "Catégories nominées",
"movies.resumeAt": "Reprendre à {time}",
"movies.userRating": "Note utilisateurs {rating}",
"movies.errorLoading": "Erreur lors du chargement.",
"movies.errorLoadingList": "Impossible de charger la liste des films. Réessaie dans quelques instants.",
"movies.errorLoading": "Impossible de charger les films. Vérifie ta connexion puis réessaie.",
"movies.unableToLoadMovies": "Impossible de charger la liste des films. Réessaie dans quelques instants.",
"movies.unableToLoadMoviesNetwork": "Impossible de charger les films. Vérifie ta connexion puis réessaie.",
"movies.loadingMovies": "Chargement des films",
"movies.fetchingData": "Récupération des données…",
"movies.viewOnImdb": "Voir sur IMDb",
"checklist.title": "Checklist",
"checklist.seen": "Vu",
"checklist.filmTitle": "Titre",
"checklist.date": "Date",
"checklist.watchedOn": "Regardé le:",
"checklist.confirmRemoval": "Confirmer le retrait",
"checklist.removeConfirmText": "Es-tu sûr de vouloir retirer {title} de ta liste?",
"checklist.moviesToWatchPerDay": "À voir par jour: {count} films",
"checklist.congratulations": "Félicitations! very nice 🎉🎉🎉",
"checklist.browserNoVideo": "Votre navigateur ne supporte pas la vidéo.",
"checklist.loadingChecklist": "Chargement de la checklist",
"checklist.errorLoading": "Erreur lors du chargement.",
"checklist.unableToLoad": "Impossible de charger la checklist. Réessaie dans quelques instants.",
"checklist.unableToLoadNetwork": "Impossible de charger la checklist. Vérifie ta connexion puis réessaie.",
"checklist.march15": "15 mars",
"checklist.loadingMovies": "Chargement des films…",
"picks.title": "Mes picks",
"picks.myPicks": "Mes picks",
"picks.pageTitleWithYear": "Pool Oscars ({year}) - Mes picks",
"picks.pageTitleChoicesWithYear": "Pool Oscars ({year}) - Mes picks",
"picks.scores": "Scores",
"picks.winners": "Gagnants",
"picks.adminBadge": "Admin",
"picks.year": "Année:",
"picks.progress": "Progression:",
"picks.reload": "Recharger",
"picks.loadingCategories": "Chargement des catégories…",
"picks.scoresCalculatedAfterWinners": "Les scores seront calculés après avoir marqué les gagnants.",
"picks.winnersMarked": "gagnants marqués",
"picks.noCategory": "Aucune catégorie",
"picks.noChoice": "Aucun choix",
"picks.category": "Catégorie",
"picks.choice": "Choix",
"picks.nominees": "Nommés",
"picks.noCategoriesFound": "Aucune catégorie trouvée pour l'année {year}",
"picks.adminMustImportCategories": "Un administrateur doit d'abord importer les catégories.",
"picks.selectionHeader": "Sélection",
"picks.winnerHeader": "Gagnant",
"picks.noCategoriesAvailable": "Aucune catégorie disponible",
"picks.winnerMarkedSuccess": "Gagnant marqué avec succès",
"picks.winnerRemoved": "Gagnant supprimé",
"picks.noWinnerMarked": "Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.",
"picks.scoresCalculated": "Scores calculés avec succès! {count} utilisateur(s) évalué(s).",
"picks.scoresRefreshed": "Scores rafraîchis",
"picks.calculatingScores": "Calcul des scores…",
"picks.calculateScores": "Calculer les scores",
"picks.dataRefreshed": "Données rafraîchies",
"picks.noScoresAvailable": "Aucun score disponible. Les utilisateurs doivent d'abord soumettre leurs choix.",
"picks.details": "Détails",
"picks.choicesOf": "Choix de {name}",
"picks.noDetailsAvailable": "Aucun détail disponible.",
"picks.summary": "Résumé",
"picks.correct": "{count} correct(s)",
"picks.incorrect": "{count} incorrect(s)",
"picks.correctPicks": "Corrects ({count})",
"picks.errorLoadingCategories": "Erreur lors du chargement des catégories: {error}",
"picks.preparingPage": "Préparation de la page…",
"picks.errorAutoSaving": "Erreur lors de l'enregistrement automatique: {error}",
"picks.incompleteSelection": "Vous avez sélectionné que {selected} sur {total} catégories. Voulez-vous enregistrer quand même?",
"picks.successSaved": "Vos choix ont été enregistrés avec succès!",
"picks.errorSubmitting": "Erreur lors de la soumission des choix: {error}",
"picks.reloaded": "Choix rechargés",
"picks.categoryHeader": "Catégorie",
"picks.userHeader": "Utilisateur",
"picks.winnerMarked": "Gagnant marqué avec succès",
"picks.winnerDeleted": "Gagnant supprimé",
"picks.errorUpdatingWinner": "Erreur: {error}",
"picks.noWinnersMarked": "Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.",
"picks.scoresCalculated": "Scores calculés avec succès! {count} utilisateur(s) évalué(s).",
"picks.errorCalculatingScores": "Erreur: {error}",
"picks.errorLoadingScores": "Erreur lors du chargement des scores.", "picks.incorrectPicks": "Incorrects ({count})",
"picks.detailsButton": "Détails",
"picks.userChoicesTitle": "Choix de {user}",
"picks.noDetailsAvailable": "Aucun détail disponible.",
"picks.summaryTitle": "Résumé",
"picks.correctCountLabel": "correct(s)",
"picks.incorrectCountLabel": "incorrect(s)",
"picks.totalCountLabel": "total",
"picks.correctSectionTitle": "Corrects ({count})",
"picks.incorrectSectionTitle": "Incorrects ({count})",
"picks.choiceLabel": "Choix",
"picks.winnerLabel": "Gagnant",
"picks.noWinnerMarkedShort": "Aucun gagnant marqué",
"picks.noChoicesToShow": "Aucun choix à afficher.",
"picks.loadingTitle": "Chargement des picks",
"picks.loadingSubtitle": "Récupération des données…",
"picks.yourChoice": "Choix: {choice}",
"picks.winner": "Gagnant: {winner}",
"picks.noWinnerMarkedYet": "Aucun gagnant marqué",
"picks.noPicksToShow": "Aucun choix à afficher.",
"picks.partialSaveConfirm": "Vous n'avez sélectionné que {selected} sur {total} catégories. Voulez-vous enregistrer quand même?",
"picks.savedSuccessfully": "Vos choix ont été enregistrés avec succès!",
"picks.errorSaving": "Erreur lors de l'enregistrement: {error}",
"picks.errorLoadingCategories": "Erreur lors du chargement des catégories: {error}",
"picks.autoSaveError": "Erreur lors de l'enregistrement automatique: {error}",
"picks.picksReloaded": "Choix rechargés",
"picks.choiceDetails": "Détails des choix",
"picks.loadingPicks": "Chargement des picks",
"viewPicks.title": "Voir Tous les Choix",
"viewPicks.overview": "Vue d'ensemble de tous les choix des utilisateurs",
"viewPicks.usersSubmitted": "{count} utilisateur(s) ont soumis leurs choix",
"viewPicks.loadingPicks": "Chargement des choix…",
"viewPicks.noPicks": "Aucun utilisateur n'a encore soumis ses choix pour l'année {year}.",
"viewPicks.picksRefreshed": "Choix rafraîchis",
"viewPicks.errorLoading": "Erreur lors du chargement des choix: {error}",
"viewPicks.unknownUser": "Utilisateur inconnu",
"viewPicks.categoriesCount": "{count} / {total} catégories",
"viewPicks.noPicks2": "Aucun choix enregistré",
"viewPicks.submittedOn": "Soumis le: {date}",
"viewPicks.winnerBadge": "Gagnant",
"player.title": "Lecteur",
"player.loading": "Chargement…",
"player.quality": "Qualité",
"player.high": "Haute",
"player.low": "Basse",
"player.rateThisFilm": "Noter ce film",
"player.star": "{count} étoile",
"player.stars": "{count} étoiles",
"player.source": "Source",
"player.progress": "Progress",
"player.noVodLink": "Aucun lien VOD configuré pour ce film.",
"player.missingMovieId": "ID du film manquant.",
"player.noPlayableSource": "Aucune source de lecture configurée pour ce film.",
"player.beginning": "Début",
"player.resuming": "Reprise",
"player.resumingAt": "Reprise à {time}",
"player.preparingResume": "Prépare la reprise…",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.sessionExpired": "Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.",
"player.beginning": "Début",
"player.resuming": "Reprise",
"player.resumingAt": "Reprise à {time}",
"player.preparingResume": "Prépare la reprise…",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.saving": "Sauvegarde…",
"player.saved": "Sauvegardé à {time}.",
"player.loadingPlayer": "Chargement du lecteur",
"player.unableToIdentifyUser": "Impossible d'identifier l'utilisateur pour sauvegarder la progression.",
"common.continueQuestion": "Continuer ?",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.sessionExpired": "Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.",
"stats.title": "Statistiques",
"stats.pageTitle": "Statistiques des utilisateurs",
"stats.ranking": "Classement",
"stats.winners": "Gagnants 🏆",
"stats.completers": "100%",
"stats.rank": "#",
"stats.name": "Nom",
"stats.films": "Films",
"stats.correctPicks": "Bon picks",
"stats.points": "Points",
"stats.year": "Année",
"stats.winnersColumn": "Gagnant(s)",
"stats.completersHelp": "Utilisateurs ayant terminé 100% (par année)",
"stats.noWinnersDefined": "Aucun gagnant défini.",
"stats.noYearsFound": "Aucune année trouvée.",
"stats.unableToLoadRanking": "Impossible de charger le classement pour le moment.",
"stats.totalFilms": "Total films",
"stats.names": "Noms",
"stats.moviesWatched": "Films regardés",
"stats.noMoviesToShow": "Aucun film à afficher.",
"stats.loading": "Chargement…",
"stats.preparingPage": "Préparation de la page…",
"stats.loadingStats": "Chargement des statistiques",
"stats.fetchingData": "Récupération des données…",
"stats.deletedUser": "(utilisateur supprimé)",
"stats.tie": "égalité",
"stats.unableToLoadCompleters": "Impossible de charger les finisseurs 100%.",
"stats.unnamed": "(sans nom)",
"stats.openLink": "Ouvrir le lien",
"stats.openPlayer": "Ouvrir le lecteur",
"stats.watchedOn": "Vu le {date}",
"stats.watchedMoviesUser": "Films regardés — {user} ({count})",
"stats.watchedMovies": "Films regardés ({count})",
"stats.filmCount": "{count} film{plural}",
"stats.noFilms": "0 films",
"admin.movies.noMovies": "Aucun film.",
"admin.title": "Pool Oscars - Admin",
"admin.tabs.settings": "Réglages",
"admin.tabs.users": "Utilisateurs",
"admin.tabs.modal100": "Modal 100%",
"admin.tabs.winners": "Gagnants",
"admin.tabs.oscarCategories": "Catégories Oscars",
"admin.tabs.backupRestore": "Backup / Restore",
"admin.tabs.version": "Version",
"admin.settings.info": "Paramètres globaux du site (année, dates, options du lecteur).",
"admin.settings.oscarDate": "Date Oscars",
"admin.settings.visibility": "Visibilité",
"admin.settings.language": "Langue",
"admin.settings.oscarDateDesc": "Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: 15 mars.",
"admin.settings.oscarDateDefaultShort": "— (défaut: 03-15)",
"admin.settings.year": "Année",
"admin.settings.applyActiveYear": "Appliquer l'année active",
"admin.settings.yearBasedOnMovies": "Basé sur les années présentes dans les films.",
"admin.settings.date": "Date",
"admin.settings.dateFormat": "Format: YYYY-MM-DD.",
"admin.settings.configuredDates": "Dates configurées",
"admin.settings.pointsDesc": "Configure les points attribués pour les films regardés et les bonnes réponses aux Oscars.",
"admin.settings.pointsPerMovie": "Points par film regardé",
"admin.settings.pointsPerMovieDesc": "Points attribués pour chaque film complété.",
"admin.settings.pointsPerCorrectPick": "Points par bonne réponse",
"admin.settings.pointsPerCorrectPickDesc": "Points attribués pour chaque bonne réponse aux Oscars.",
"admin.settings.reload": "Recharger",
"admin.settings.pointsCalculation": "Calcul des points",
"admin.settings.total": "Total",
"admin.settings.moviesWatched": "Films regardés",
"admin.settings.correctPicks": "Bons picks",
"admin.settings.pointsSaved": "Configuration enregistrée: {p} pts/film, {q} pts/bonne réponse",
"admin.settings.noYearAddMovie": "Aucune année (ajoute un film)",
"admin.visibility.showPicksFeatures": "Afficher les fonctionnalités \"Picks\"",
"admin.visibility.showPicksFeaturesDesc": "Contrôle l'affichage du bouton \"Mes picks\" dans la navigation et de la colonne \"Bon picks\" dans les statistiques.",
"admin.visibility.showSourceProgress": "Afficher \"Source\" + \"Progress\" (admins)",
"admin.visibility.showSourceProgressDesc": "Contrôle l'affichage du bloc debug dans player.html (admins uniquement).",
"admin.visibility.showVersionTab": "Afficher l'onglet \"Version\"",
"admin.visibility.showVersionTabDesc": "Contrôle l'affichage de l'onglet \"Version\" dans le panneau admin.",
"admin.visibility.saved": "Configuration enregistrée",
"admin.visibility.saveError": "Erreur lors de l'enregistrement : {error}",
"admin.language.siteLanguage": "Langue du site",
"admin.language.siteLanguageDesc": "Change la langue pour tous les utilisateurs. Rafraîchissement de page requis.",
"admin.version.info": "Contrôle la version affichée dans le footer (stockée en DB). Clique sur ✓ pour activer une version, puis vérifie l'aperçu.",
"admin.version.createNew": "Créer une nouvelle version",
"admin.version.createNewDesc": "Ajoute une entrée dans l'historique (tu peux l'activer immédiatement).",
"admin.version.version": "Version",
"admin.version.versionPlaceholder": "ex: 1.0.1",
"admin.version.versionExample": "Ex: 1.2.3",
"admin.version.messageOptional": "Message (optionnel)",
"admin.version.messagePlaceholder": "ex: Hotfix login",
"admin.version.messageDesc": "Court & lisible (affiché dans le footer).",
"admin.version.create": "Créer",
"admin.version.createAndActivate": "Créer & activer",
"admin.version.created": "Version créée.",
"admin.version.createdAndActivated": "Version créée & activée.",
"admin.version.confirmDelete": "Supprimer cette version ?",
"admin.backup.chooseFile": "Choisis un fichier de sauvegarde (.ndjson.gz).",
"admin.backup.restoreWithDrop": "RESTORE avec DROP: toutes les collections restaurées seront remplacées.",
"admin.backup.restoreWithoutDrop": "RESTORE sans DROP: risque élevé de doublons/erreurs si la DB contient déjà des données.",
"admin.backup.noDocumentsInserted": "Aucun document inséré (ou backup vide).",
"admin.modal100.info": "Contenu affiché quand un utilisateur a vu 100% des films.",
"admin.modal100.content": "Contenu",
"admin.modal100.contentDesc": "Modifie le contenu puis enregistre. L'aperçu est live (non sauvegardé tant que tu n'enregistres pas).",
"admin.modal100.titleLabel": "Titre",
"admin.modal100.titlePlaceholder": "Félicitations! ...",
"admin.modal100.textOptional": "Texte (optionnel)",
"admin.modal100.textPlaceholder": "Texte affiché dans le modal (retours à la ligne conservés)",
"admin.modal100.textDesc": "Affiché en texte brut (safe). Laisse vide si tu n'en veux pas.",
"admin.modal100.htmlOptional": "HTML (optionnel)",
"admin.modal100.htmlDesc": "Affiché comme HTML (le serveur retire les <script> et les handlers on*).",
"admin.modal100.videoSrc": "Video src (optionnel)",
"admin.modal100.videoSrcDesc": "Chemin relatif (ex: video/reward.mp4) ou URL complète. Vide = pas de vidéo.",
"admin.modal100.browserNoVideo": "Votre navigateur ne supporte pas la vidéo.",
"admin.modal100.textLabel": "Texte",
"admin.modal100.textInfoShort": "affiché tel quel (safe), retours à la ligne conservés.",
"admin.modal100.htmlInfoShort": "le serveur retire les <script> et les attributs on*.",
"admin.modal100.videoLabel": "Vidéo",
"admin.modal100.videoInfoShort": "accepte un chemin relatif (ex: video/reward.mp4) ou une URL complète.",
"admin.modal100.defaultTitle": "Félicitations! very nice 🎉🎉🎉",
"admin.users.usersCount": "utilisateur(s)",
"admin.users.addUser": "Ajouter un utilisateur",
"admin.users.name": "Nom",
"admin.users.status": "Statut",
"admin.users.actions": "Actions",
"admin.users.loading": "Charge la liste…",
"admin.users.resetPassword": "Reset MDP",
"admin.users.resetPasswordDesc": "Génère un mot de passe temporaire. L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.tempPasswordOnce": "Mot de passe temporaire (affiché une seule fois)",
"admin.users.generate": "Générer",
"admin.users.deleteUser": "Supprimer un utilisateur",
"admin.users.aboutToDelete": "Tu es sur le point de supprimer:",
"admin.users.actionIrreversible": "Cette action est irréversible.",
"admin.users.typeEmailToConfirm": "Pour confirmer, tape l'email exact:",
"admin.users.emailPlaceholder": "[email protected]",
"admin.users.deleteButtonActivates": "Le bouton \"Supprimer\" s'activera seulement si l'email correspond.",
"admin.users.addUserDesc": "Crée un compte avec un mot de passe temporaire (affiché une seule fois). L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.namePlaceholder": "Prénom / pseudo",
"admin.users.reset": "Reset",
"admin.users.cannotDeleteSelf": "Impossible de supprimer ton propre compte via cette interface.",
"admin.users.adminDeleteDisabled": "La suppression des comptes admin est désactivée via cette interface.",
"admin.users.creating": "Création…",
"admin.users.nameRequired": "Nom requis.",
"admin.users.emailRequired": "Email requis.",
"admin.users.userCreated": "Utilisateur créé. Mot de passe temporaire généré.",
"admin.users.tempPasswordExpires": "Expire: {expiresAt}",
"admin.users.tempPasswordGenerating": "Génération du mot de passe temporaire…",
"admin.users.tempPasswordGenerated": "Mot de passe temporaire généré. Copie-le et envoie-le à l'utilisateur.",
"admin.users.tempPasswordGeneratedOnReset": "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.cannotCopyAuto": "Impossible de copier automatiquement. Copie manuellement.",
"admin.users.copied": "Copié.",
"admin.movies.idCopied": "Movie ID copié.",
"admin.movies.addedWithSubtitles": "Film ajouté avec sous-titres.",
"admin.movies.addedSuccessfully": "Film ajouté avec succès.",
"admin.movies.addedNoSubtitles": "Film ajouté, mais sous-titres non uploadés: {error}",
"admin.movies.updatedSuccessfully": "Film mis à jour avec succès.",
"admin.movies.updatedNoSubtitles": "Film mis à jour, mais sous-titres non uploadés: {error}",
"admin.language.saved": "Langue enregistrée",
"admin.movies.addInfo": "Ajout de films : cette section récupère automatiquement les infos du film via l'imdb_id (OMDb).",
"admin.movies.oscarYear": "Oscar year",
"admin.movies.oscarYearDesc": "Sépare films & stats par année.",
"admin.movies.category": "Catégorie",
"admin.movies.allYears": "Toutes",
"admin.movies.refreshOmdb": "Refresh OMDb",
"admin.movies.selectAll": "Tout sélectionner",
"admin.movies.selected": "sélectionné(s)",
"admin.movies.sel": "Sel.",
"admin.movies.titleCol": "Titre",
"admin.movies.player": "Player",
"admin.movies.year": "Année",
"admin.movies.editMovie": "Modifier le film",
"admin.movies.info": "Info",
"admin.movies.playerVideo": "Player / Vidéo",
"admin.movies.imdbIdLabel": "IMDB ID (ex: tt1234567)",
"admin.movies.rating": "Rating (imdbRating)",
"admin.movies.runtime": "Runtime",
"admin.movies.runtimeExample": "Ex: 142 min",
"admin.movies.camTag": "Tag CAM",
"admin.movies.refreshFromOmdb": "Recharger depuis OMDb (écrase titre / plot / rating / runtime / poster)",
"admin.movies.refreshFromOmdbDesc": "Si coché, le serveur refait un appel OMDb via l'IMDB ID au moment de l'enregistrement.",
"admin.movies.posterUrl": "Poster URL",
"admin.movies.description": "Description (Plot)",
"admin.movies.playerSource": "Lecteur (source)",
"admin.movies.mode": "Mode",
"admin.movies.modeDesc": "Auto = video_src → embed_src → vod_link.",
"admin.movies.videoUrl": "Video URL (mp4/webm/m3u8)",
"admin.movies.videoUrlDesc": "Si tu utilises un fichier serveur (ci-dessous) et que tu laisses vide, il utilisera auto /api/video/<movieId>.",
"admin.movies.serverVideoFile": "Fichier vidéo serveur (optionnel)",
"admin.movies.serverVideoFileDesc": "Chemin relatif sous VIDEO_FILES_DIR (défaut: public/video).",
"admin.movies.serverVideoFileLow": "Fichier vidéo serveur (basse qualité, optionnel)",
"admin.movies.serverVideoFileLowDesc": "Chemin relatif sous VIDEO_FILES_DIR. Exemple: LOW/123.LOW.mp4.",
"admin.movies.subtitles": "Sous-titres (SRT, optionnel)",
"admin.movies.existingSubtitles": "Sous-titres existants:",
"admin.movies.subtitlesNote": "Ajoute un fichier par langue; répète l'upload pour plusieurs sous-titres.",
"admin.movies.removeAllSubtitles": "Supprimer tous les sous-titres",
"admin.movies.removeAllSubtitlesDesc": "Efface les fichiers VTT/SRT et réinitialise les champs de langue/label.",
"admin.movies.confirmRemoveSubtitle": "Supprimer ce sous-titre ?",
"admin.movies.confirmDeleteSelected": "Supprimer {count} film(s) ? Cette action est irréversible.",
"admin.movies.subtitleLang": "Langue (ex: fr, en)",
"admin.movies.subtitleLabel": "Label (affiché)",
"admin.movies.subtitleDefault": "Par défaut",
"admin.movies.embedUrl": "Embed URL or iframe code",
"admin.movies.legacyVodLink": "Legacy VOD link (optionnel)",
"admin.backup.info": "Sauvegarde et restauration de la base MongoDB.",
"admin.backup.backup": "Backup",
"admin.backup.restore": "Restore",
"admin.backup.help": "Aide",
"admin.backup.backups": "Sauvegardes",
"admin.backup.backupsDesc": "Format .ndjson.gz, stocké sur le serveur.",
"admin.backup.createBackup": "Créer une sauvegarde",
"admin.backup.file": "Fichier",
"admin.backup.size": "Taille",
"admin.backup.modified": "Modifié",
"admin.backup.openTabToLoad": "Ouvre l'onglet pour charger la liste…",
"admin.backup.restoreBackup": "Restaurer une sauvegarde",
"admin.backup.restoreDesc": "Restaure un fichier .ndjson.gz. L'option drop est recommandée.",
"admin.backup.restoreWarning": "La restauration peut écraser les données. Fais un backup juste avant si besoin.",
"admin.backup.backupFile": "Fichier de backup",
"admin.backup.dropBeforeRestore": "Drop les collections avant restore (recommandé)",
"admin.backup.tip": "Conseil",
"admin.backup.tipDesc": "Utilise drop sauf si tu sais exactement ce que tu fais (sinon risque de doublons/erreurs).",
"admin.backup.howItWorks": "Fonctionnement",
"admin.backup.backupHowDesc": "crée un fichier .ndjson.gz (export MongoDB) et le garde sur le serveur.",
"admin.backup.download": "Télécharger",
"admin.backup.downloadDesc": "récupère le fichier localement (utile avant une manipulation risquée).",
"admin.backup.restoreHowDesc": "importe le fichier. Avec drop, les collections restaurées sont d'abord supprimées.",
"admin.backup.quickCheck": "Check rapide",
"admin.backup.quickCheckDesc": "Après restore, recharge la page et vérifie \"Films\", \"Checklist\" et \"Statistiques\".",
"admin.backup.confirmDelete": "Supprimer la sauvegarde \"{name}\" ?",
"admin.backup.creating": "Création…",
"admin.backup.created": "Sauvegarde créée : {name}",
"admin.categories.info": "Créez et gérez les catégories manuellement.",
"admin.categories.manualCreation": "Création manuelle",
"admin.categories.categoryCreated": "Catégorie créée avec succès.",
"admin.categories.list": "Liste",
"admin.categories.createManually": "Créer une catégorie manuellement",
"admin.categories.createManuallyDesc": "Ajoutez une catégorie et ses nommés un par un.",
"admin.categories.categoryNumber": "Numéro de catégorie",
"admin.categories.categoryName": "Nom de la catégorie",
"admin.categories.nomineesOnePerLine": "Nommés (un par ligne)",
"admin.categories.nomineesDesc": "Entrez un nommé par ligne.",
"admin.categories.note": "Note",
"admin.categories.noteDesc": "Vous pouvez créer plusieurs catégories pour la même année. Le numéro de catégorie doit être unique par année.",
"admin.categories.importedCategories": "Catégories importées",
"admin.categories.manageExisting": "Gérez les catégories existantes",
"admin.categories.allYears": "Toutes les années",
"admin.categories.categoriesSelected": "catégorie(s) sélectionnée(s)",
"admin.categories.editCategory": "Modifier la catégorie",
"admin.categories.selectAllTitle": "Sélectionner tout",
"admin.categories.table.number": "#",
"admin.categories.table.name": "Nom",
"admin.categories.table.nominees": "Nommés",
"admin.categories.table.actions": "Actions",
"admin.categories.confirmDelete": "Êtes-vous sûr de vouloir supprimer cette catégorie?",
"admin.categories.confirmDeleteMultiple": "Êtes-vous sûr de vouloir supprimer {count} catégorie(s)?\n\n{names}\n\nCette action est irréversible.",
"admin.categories.noneFound": "Aucune catégorie trouvée.",
"admin.categories.noneFoundYear": "Aucune catégorie trouvée pour l'année {year}.",
"admin.winners.info": "Définis un ou plusieurs gagnants par année (égalité possible).",
"admin.winners.manage": "Gérer",
"admin.winners.addWinner": "Ajouter un gagnant",
"admin.winners.addWinnerDesc": "Tu peux ajouter plusieurs gagnants pour une même année (égalité).",
"admin.winners.user": "Utilisateur",
"admin.winners.removeNote": "Pour retirer: utilise la liste ci-dessous.",
"admin.winners.pointsOptional": "Points (optionnel)",
"admin.winners.pointsPlaceholder": "ex: 42",
"admin.winners.pointsDesc": "Laisse vide si non applicable.",
"admin.winners.deleteAll": "Tout supprimer",
"admin.winners.currentWinners": "Gagnants actuels",
"admin.winners.globalView": "Vue globale (toutes les années).",
"admin.winners.howItWorks": "Comment ça marche",
"admin.winners.multipleWinners": "Une année peut avoir plusieurs gagnants (égalité).",
"admin.winners.tieLabel": "(égalité)",
"admin.winners.tie": "(égalité)",
"admin.winners.chooseUser": "Choisis un utilisateur.",
"admin.winners.confirmDeleteAll": "Supprimer tous les gagnants pour {year} ?",
"admin.player.errorLoading": "Impossible de charger les réglages du lecteur.",
"admin.versions.updated": "Version mise à jour.",
"admin.versions.deleted": "Version supprimée.",
"admin.winners.pointsOptionalInfo": "Les points sont optionnels (affichage uniquement).",
"admin.winners.removeInfo": "Pour retirer un gagnant, utilise le bouton \"Retirer\" dans la liste \"Gagnants actuels\".",
"admin.winners.tipDesc": "Si tu changes l'année active du site, vérifie aussi les gagnants de cette année.",
"admin.winners.removedFor": "Gagnant retiré pour {year}.",
"admin.winners.reloaded": "Gagnants rechargés.",
"admin.winners.deletedFor": "Gagnant supprimé pour {year}.",
"admin.winners.addedFor": "Gagnant ajouté pour {year}.",
"admin.backup.deleted": "Sauvegarde supprimée.",
"admin.backup.restoreComplete": "Restore terminé.",
"admin.movies.deletedSuccessfully": "Film(s) supprimé(s) avec succès.",
"admin.movies.updatedFromOmdb": "{count} film(s) mis à jour depuis OMDb.",
"admin.movies.omdbResults": "{success} succès, {errors} échec(s). {details}",
"admin.movies.subtitleDeleted": "Sous-titre supprimé.",
"admin.modal100.saved": "Modal 100% enregistré.",
"admin.content.reloaded": "Contenu rechargé.",
"admin.oscars.invalidYear": "Année invalide. Exemple attendu: 2026",
"admin.oscars.dateSaved": "Date Oscars enregistrée pour {year}: {date}",
"admin.oscars.dateCleared": "Date Oscars effacée pour {year} (retour au défaut: 15 mars).",
"admin.player.settingsSaved": "Réglages du lecteur enregistrés.",
"admin.player.settingsReloaded": "Réglages du lecteur rechargés.",
"admin.activeYear.applied": "Année active appliquée: {year}",
"admin.configuration.reloaded": "Configuration rechargée.",
"admin.categories.categoryNotFound": "Catégorie non trouvée.",
"admin.categories.categoryDeleted": "Catégorie supprimée.",
"admin.categories.categoryUpdated": "Catégorie mise à jour avec succès.",
"admin.categories.deletedCount": "{count} catégorie(s) supprimée(s){errors}",
"admin.oscars.invalidYearShort": "Année invalide.",
"admin.movies.subtitleDeleted": "Sous-titre supprimé.",
"admin.categories.categoryInvalid": "Catégorie invalide.",
"footer.oscarPool": "Oscar Pool",
"footer.by": "par",
"footer.versionNotConfigured": "Maintenant en Full HD",
"footer.versionUnavailable": "Version indisponible (DB)."
}
+35 -16
View File
@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Films</title>
<title data-i18n="movies.title">Oscar Pool - Films</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -39,7 +57,7 @@
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
@@ -58,16 +76,16 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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 id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -88,7 +106,7 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
@@ -100,34 +118,35 @@
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div class="text-muted small">
Dernière mise à jour des films: <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
<span data-i18n="movies.lastUpdated">Last movie update:</span> <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
</div>
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
<div class="text-muted small" id="time-until-oscars-container">
<span data-i18n="movies.timeUntilOscarsLabel">Time until the Oscars</span>
&nbsp;(<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
</div>
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2 mt-3">
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
<label class="form-check-label" for="flexSwitchCheckDefault" data-i18n="movies.unwatchedOnly">Unwatched films only</label>
</div>
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</div>
<div class="text-muted small" data-i18n="movies.clickToPlay">Click the poster or title to open the player.</div>
</div>
</div>
</div>
<div id="movies-list" class="row g-3 mt-1">
<div class="col-12">
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</div>
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</div>
</div>
</div>
</div>
<!-- Footer Section -->
<footer>
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
+47 -30
View File
@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Mes picks</title>
<title data-i18n="picks.title">Oscar Pool - My picks</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -113,7 +131,7 @@
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
@@ -131,15 +149,15 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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 id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -160,7 +178,7 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
@@ -172,19 +190,19 @@
<!-- Tabs -->
<ul class="nav nav-tabs" id="picksTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-my-picks" data-bs-toggle="tab" data-bs-target="#pane-my-picks" type="button" role="tab" aria-controls="pane-my-picks" aria-selected="true">
Mes picks
<button class="nav-link active" id="tab-my-picks" data-bs-toggle="tab" data-bs-target="#pane-my-picks" type="button" role="tab" aria-controls="pane-my-picks" aria-selected="true" data-i18n="picks.myPicks">
My picks
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-scores" data-bs-toggle="tab" data-bs-target="#pane-scores" type="button" role="tab" aria-controls="pane-scores" aria-selected="false">
<button class="nav-link" id="tab-scores" data-bs-toggle="tab" data-bs-target="#pane-scores" type="button" role="tab" aria-controls="pane-scores" aria-selected="false" data-i18n="picks.scores">
Scores
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link d-none" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
Gagnants
<span class="badge bg-warning text-dark ms-1">Admin</span>
<span data-i18n="picks.winners">Winners</span>
<span class="badge bg-warning text-dark ms-1" data-i18n="picks.adminBadge">Admin</span>
</button>
</li>
</ul>
@@ -194,16 +212,16 @@
<div class="tab-pane fade show active" id="pane-my-picks" role="tabpanel" aria-labelledby="tab-my-picks" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="current-year">—</span></span>
<span class="text-muted small ms-2">Progression: <span id="picks-progress">0 / 0</span></span>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="current-year">—</span></span>
<span class="text-muted small ms-2"><span data-i18n="picks.progress">Progress:</span> <span id="picks-progress">0 / 0</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary" data-i18n="picks.reload">Reload</button>
</div>
</div>
<div id="categories-container">
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
</div>
</div>
@@ -211,15 +229,15 @@
<div class="tab-pane fade" id="pane-scores" role="tabpanel" aria-labelledby="tab-scores" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="scores-year">—</span></span>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="scores-year">—</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-scores" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
<button type="button" id="refresh-scores" class="btn btn-sm btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div id="scores-container">
<div class="text-center py-3 text-muted small">Les scores seront calculés après avoir marqué les gagnants.</div>
<div class="text-center py-3 text-muted small" data-i18n="picks.scoresCalculatedAfterWinners">Scores will be calculated after marking winners.</div>
</div>
</div>
@@ -227,16 +245,16 @@
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold">Année: <span id="winners-year">—</span></span>
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> gagnants marqués</span>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="winners-year">—</span></span>
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> <span data-i18n="picks.winnersMarked">winners marked</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div id="winners-container">
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
</div>
</div>
</div>
@@ -247,16 +265,16 @@
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userPicksModalLabel">Détails des choix</h5>
<h5 class="modal-title" id="userPicksModalLabel" data-i18n="picks.choiceDetails">Choice details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="user-picks-modal-content">
<div class="text-center py-4 text-muted">Chargement...</div>
<div class="text-center py-4 text-muted" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.close">Close</button>
</div>
</div>
</div>
@@ -264,7 +282,7 @@
<!-- Footer Section -->
<footer>
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
@@ -274,4 +292,3 @@
</body>
</html>
+41 -24
View File
@@ -5,11 +5,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- IMPORTANT: the player may use ?token=... for cross-origin video streaming -->
<meta name="referrer" content="no-referrer" />
<title>Pool Oscars</title>
<title data-i18n="player.title">Oscar Pool - Player</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -193,7 +211,7 @@
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
@@ -212,15 +230,15 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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 id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -241,7 +259,7 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
@@ -255,7 +273,7 @@
<div class="card-body">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<h2 id="movie-title" class="mb-1">Chargement…</h2>
<h2 id="movie-title" class="mb-1" data-i18n="common.loading">Loading…</h2>
<div class="text-muted small">
<span id="movie-category"></span><span id="movie-rating"></span>
</div>
@@ -263,20 +281,20 @@
<div class="d-flex flex-column gap-2 align-items-center align-items-lg-center">
<div id="quality-toggle" class="d-none quality-toggle">
<span class="quality-label">Qualité</span>
<div class="quality-pill" role="group" aria-label="Qualité">
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true">Haute</button>
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false">Basse</button>
<span class="quality-label" data-i18n="player.quality">Quality</span>
<div class="quality-pill" role="group" aria-label="Quality">
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true" data-i18n="player.high">High</button>
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false" data-i18n="player.low">Low</button>
</div>
</div>
<div id="user-rating" class="user-rating">
<div class="rating-stars" role="group" aria-label="Noter ce film">
<button type="button" class="star-btn" data-value="1" aria-label="1 étoile" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="2" aria-label="2 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="3" aria-label="3 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="4" aria-label="4 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="5" aria-label="5 étoiles" aria-pressed="false">★</button>
<div class="rating-stars" role="group" data-i18n-aria-label="player.rateThisFilm" aria-label="Rate this film">
<button type="button" class="star-btn" data-value="1" aria-label="1 star" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="2" aria-label="2 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="3" aria-label="3 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="4" aria-label="4 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="5" aria-label="5 stars" aria-pressed="false">★</button>
</div>
<div id="user-rating-meta" class="text-muted small">—</div>
</div>
@@ -284,12 +302,12 @@
<!-- Admin-only debug/status (Source + Progress) -->
<div id="player-admin-status" class="d-none d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Source</span>
<span class="badge bg-secondary" data-i18n="player.source">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span class="badge bg-secondary" data-i18n="player.progress">Progress</span>
<span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
@@ -321,7 +339,7 @@
<!-- Footer Section -->
<footer class="footer--player">
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
@@ -332,4 +350,3 @@
<script src="./js/app_version.js"></script>
</body>
</html>
+40 -24
View File
@@ -3,21 +3,37 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Enregistrement</title>
<title data-i18n="auth.register">Oscar Pool - Register</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
#dog {
width: 30%;
margin: 5px;
padding: 5px;
border-radius: 10%;
}
</style>
<script src="./js/localization.js" defer></script>
</head>
<style>
#dog {
width: 30%;
margin: 5px;
padding: 5px;
border-radius: 10%;
}
</style>
<body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -43,41 +59,41 @@
<!-- 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">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div>
<div class="alert alert-warning mb-3">
<strong>Configuration requise</strong><br>
L'application doit d'abord être configurée par un administrateur.
<strong data-i18n="auth.setupRequired">Setup required</strong><br>
<span data-i18n="auth.appMustBeConfigured">The application must first be configured by an administrator.</span>
</div>
<a href="/" class="btn btn-primary w-100">Aller à la page de configuration</a>
<a href="/" class="btn btn-primary w-100" data-i18n="auth.goToSetup">Go to setup page</a>
</div>
<!-- Normal registration card -->
<div id="register-card" class="card p-4" style="max-width: 400px;">
<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="Oscar Pool">
</div>
<form id="register-form">
<div class="mb-3">
<label for="name" class="form-label">Nom</label>
<label for="name" class="form-label" data-i18n="auth.name">Name</label>
<input type="text" class="form-control" id="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Courriel</label>
<label for="email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<label for="password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="password" required>
</div>
<div class="mb-3">
<label for="verifoof" class="form-label">Le nom du chien à Cath & Nath?</label>
<label for="verifoof" class="form-label" data-i18n="auth.dogQuestion">What is Cath & Nath's dog's name?</label>
<img id="dog" src="img/doglmao.png">
<input type="text" class="form-control" id="verifoof" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
<button type="submit" class="btn btn-primary w-100" data-i18n="auth.register">Register</button>
</form>
<p class="mt-3 text-center">Déjà un compte? <a href="/">Connectez-vous</a></p>
<p class="mt-3 text-center"><span data-i18n="auth.hasAccount">Already have an account?</span> <a href="/" data-i18n="auth.signIn">Sign in</a></p>
</div>
</div>
@@ -86,15 +102,15 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="responseModalLabel">Statut de l’enregistrement</h5>
<h5 class="modal-title" id="responseModalLabel" data-i18n="auth.registrationStatus">Registration status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="response-message">
<!-- Response message will be inserted here -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="button" class="btn btn-primary" id="redirect-login">Retourner à la page d'accueil</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.close">Close</button>
<button type="button" class="btn btn-primary" id="redirect-login" data-i18n="auth.returnToHome">Return to home page</button>
</div>
</div>
</div>
+48 -30
View File
@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Statistiques des utilisateurs</title>
<title data-i18n="stats.title">Oscar Pool - Statistics</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -95,7 +113,7 @@
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
@@ -114,16 +132,16 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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 id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Movies</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -144,7 +162,7 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
@@ -155,17 +173,17 @@
<ul class="nav nav-tabs stats-tabs" id="statsTabs" role="tablist">
<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">
Classement
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Année active"></span>
<span data-i18n="stats.ranking">Ranking</span>
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Active year"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
Gagnants 🏆
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false" data-i18n="stats.winners">
Winners 🏆
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false">
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false" data-i18n="stats.completers">
100%
</button>
</li>
@@ -180,11 +198,11 @@
<table class="table table-striped table-hover align-middle user-table d-none">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Nom</th>
<th scope="col" id="films-header">Films</th>
<th scope="col" id="bon-picks-header">Bon picks</th>
<th scope="col" class="text-center">Points</th>
<th scope="col" data-i18n="stats.rank">#</th>
<th scope="col" data-i18n="stats.name">Name</th>
<th scope="col" id="films-header" data-i18n="stats.films">Movies</th>
<th scope="col" id="bon-picks-header" data-i18n="stats.correctPicks">Correct picks</th>
<th scope="col" class="text-center" data-i18n="stats.points">Points</th>
</tr>
</thead>
<tbody id="user-table-body">
@@ -199,13 +217,13 @@
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;">Année</th>
<th scope="col">Gagnant(s)</th>
<th scope="col" style="width: 110px;" data-i18n="stats.year">Year</th>
<th scope="col" data-i18n="stats.winnersColumn">Winner(s)</th>
</tr>
</thead>
<tbody id="winners-table-body">
<tr>
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</td>
</tr>
</tbody>
</table>
@@ -216,20 +234,20 @@
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
<div class="small text-muted mb-2 d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border" aria-hidden="true">i</span>
<span>Utilisateurs ayant terminé 100% (par année)</span>
<span data-i18n="stats.completersHelp">Users who have watched 100% (by year)</span>
</div>
<div class="table-responsive mb-4">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;">Année</th>
<th scope="col" style="width: 140px;">Total films</th>
<th scope="col">Noms</th>
<th scope="col" style="width: 110px;" data-i18n="stats.year">Year</th>
<th scope="col" style="width: 140px;" data-i18n="stats.totalFilms">Total films</th>
<th scope="col" data-i18n="stats.names">Names</th>
</tr>
</thead>
<tbody id="completers-table-body">
<tr>
<td colspan="3" 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" data-i18n="common.loading">Loading…</td>
</tr>
</tbody>
</table>
@@ -243,14 +261,14 @@
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5>
<h5 class="modal-title" id="moviesModalLabel" data-i18n="stats.moviesWatched">Movies watched</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex flex-column gap-3">
<div id="movies-summary" class="text-muted small"></div>
<div id="movies-empty" class="text-muted d-none">Aucun film à afficher.</div>
<div id="movies-empty" class="text-muted d-none" data-i18n="stats.noMoviesToShow">No movies to show.</div>
<div id="movie-grid" class="stats-movies-grid" aria-live="polite"></div>
</div>
</div>
@@ -260,7 +278,7 @@
<!-- Footer Section -->
<footer>
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
+34 -17
View File
@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Voir Tous les Choix</title>
<title data-i18n="viewPicks.title">Oscar Pool - View All Picks</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
@@ -58,16 +76,16 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<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="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="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="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a href="control.html" class="nav-link btn btn-warning me-2" data-i18n="nav.admin">Admin</a>
<a href="view-picks.html" class="nav-link btn btn-info me-2 active" data-i18n="nav.viewAllPicks">View All Picks</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
@@ -88,14 +106,14 @@
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
</nav>
<div class="container mt-4 pb-5">
<h2>Voir Tous les Choix</h2>
<h2 data-i18n="viewPicks.title">View All Picks</h2>
<div id="alert-container"></div>
@@ -103,29 +121,29 @@
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold">Année: <span id="current-year">—</span></div>
<div class="text-muted small">Vue d'ensemble de tous les choix des utilisateurs</div>
<div class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="current-year">—</span></div>
<div class="text-muted small" data-i18n="viewPicks.overview">Overview of all user picks</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-picks" class="btn btn-outline-secondary">Rafraîchir</button>
<button type="button" id="refresh-picks" class="btn btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
<span id="users-count">0</span> <span data-i18n="viewPicks.usersSubmitted" data-i18n-params='{"count": ""}'>user(s) have submitted their picks</span>
</div>
</div>
</div>
</div>
<div id="picks-container">
<div class="text-center py-4 text-muted">Chargement des choix…</div>
<div class="text-center py-4 text-muted" data-i18n="viewPicks.loadingPicks">Loading picks…</div>
</div>
</div>
<!-- Footer Section -->
<footer>
<p id="footer-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
@@ -135,4 +153,3 @@
</body>
</html>
+53 -3
View File
@@ -16,6 +16,7 @@ const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const VISIBILITY_CONFIG_KEY = "visibility_config";
const LANGUAGE_KEY = "site_language";
const crypto = require("crypto");
@@ -38,10 +39,14 @@ const DEFAULT_POINTS_CONFIG = Object.freeze({
});
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
showPicksButton: true,
showBonPicksColumn: true,
showPicksButton: false,
showBonPicksColumn: false,
showVersionTab: true,
});
const DEFAULT_LANGUAGE = "en";
const SUPPORTED_LANGUAGES = Object.freeze(["en", "fr"]);
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
@@ -772,7 +777,8 @@ function normalizeVisibilityConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
return { showPicksButton, showBonPicksColumn };
const showVersionTab = typeof v.showVersionTab === "boolean" ? v.showVersionTab : DEFAULT_VISIBILITY_CONFIG.showVersionTab;
return { showPicksButton, showBonPicksColumn, showVersionTab };
}
// Public: get points configuration
@@ -851,5 +857,49 @@ router.put("/visibility-config", async (req, res) => {
}
});
// Language normalization
function normalizeLanguage(raw) {
const lang = String(raw || "").trim().toLowerCase();
return SUPPORTED_LANGUAGES.includes(lang) ? lang : DEFAULT_LANGUAGE;
}
// Public: get current language setting
router.get("/language", async (req, res) => {
try {
const existing = await Setting.findOne({ key: LANGUAGE_KEY }).select("value");
const language = normalizeLanguage(existing?.value);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
return res.status(200).json({ language: DEFAULT_LANGUAGE, supported: SUPPORTED_LANGUAGES });
}
});
// Admin-only: set language
router.put("/language", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const language = normalizeLanguage(req.body?.language);
await Setting.findOneAndUpdate(
{ key: LANGUAGE_KEY },
{ $set: { value: language } },
{ upsert: true, new: true }
);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
module.exports = router;
+24 -7
View File
@@ -501,7 +501,10 @@ router.post("/register", async (req, res) => {
throw new Error("Le nom du chien doit être défini");
}
if (!registerVerification()) {
return res.status(400).json({ message: "Mauvais nom de chien" });
return res.status(400).json({
messageKey: 'auth.dogAnswerIncorrect',
message: 'Incorrect dog name.',
});
}
const existingUser = await User.findOne({ email: emailNorm });
@@ -515,7 +518,10 @@ router.post("/register", async (req, res) => {
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
await user.save();
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
res.status(201).json({
messageKey: 'auth.registrationSuccess',
message: 'User registered successfully!',
});
} catch (err) {
return res.status(400).json({ message: err?.message || "Erreur interne" });
}
@@ -527,19 +533,30 @@ router.post("/login", async (req, res) => {
try {
const user = await User.findOne({ email });
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
if (!user) {
return res.status(404).json({
messageKey: 'auth.userNotFound',
message: 'User not found',
});
}
// If this account is in "temp password" mode and the temp password has expired, block login.
if (user.mustChangePassword && user.tempPasswordExpiresAt && user.tempPasswordExpiresAt instanceof Date) {
if (Date.now() > user.tempPasswordExpiresAt.getTime()) {
return res.status(401).json({
message: "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
messageKey: 'auth.tempPasswordExpired',
message: 'Temporary password expired. Ask an admin to reset your password.',
});
}
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
if (!isMatch) {
return res.status(401).json({
messageKey: 'auth.invalidCredentials',
message: 'Invalid credentials',
});
}
console.log("[debug] " + user.name + " logged in")
@@ -613,7 +630,7 @@ router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
await user.save();
return res.status(200).json({
message: "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
message: "Temporary password generated. The user must change it on next login.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email },
@@ -697,7 +714,7 @@ router.post("/admin/create", verifyToken, async (req, res) => {
await user.save();
return res.status(201).json({
message: "Utilisateur créé. Mot de passe temporaire généré.",
message: "User created. Temporary password generated.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
+4 -2
View File
@@ -79,7 +79,8 @@ router.get("/", async (req, res) => {
if (!active) {
return res.status(200).json({
version: getPackageVersion(),
message: "Version non configurée (définis-la dans Admin → Version).",
messageKey: 'footer.versionNotConfigured',
message: 'Version not configured (set it in Admin → Version).',
author: "",
date: nowFormatted(),
configured: false,
@@ -103,7 +104,8 @@ router.get("/", async (req, res) => {
// If DB is down, still return something useful.
return res.status(200).json({
version: getPackageVersion(),
message: "Version indisponible (DB).",
messageKey: 'footer.versionUnavailable',
message: 'Version unavailable (DB).',
author: "",
date: nowFormatted(),
configured: false,