Mr. Worldwide #93
+14
@@ -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
@@ -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
@@ -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>
|
||||
(<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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
(<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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
+35
-19
@@ -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">
|
||||
</head>
|
||||
<style>
|
||||
<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>
|
||||
</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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user