Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2821b55e4b | ||
|
|
d8330579ec | ||
|
|
cc729de70e | ||
|
|
ab67206c71 | ||
|
|
12d9d641eb | ||
|
|
b0ff5e598e | ||
|
|
1492e5e568 | ||
|
|
792f2350ad |
+1
-15
@@ -165,13 +165,6 @@ eng.traineddata
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# VS Code project settings
|
||||
.vscode/
|
||||
|
||||
# AI assistant/editor metadata
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
# Python cache files
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -180,11 +173,4 @@ __pycache__/
|
||||
.Python
|
||||
|
||||
# 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
|
||||
temp/
|
||||
@@ -1,16 +1,11 @@
|
||||
## Oscars Pool 2026
|
||||
Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep track of watched movies for an incoming Oscars Pool with some friends.
|
||||
|
||||
**Note:**
|
||||
**WIP** This project still has bugs, some visual glitches and need some work.
|
||||
**Note:** The application interface is in French.
|
||||
|
||||
The application interface now supports **English and French** via JSON locale bundles (default is English; the admin can force the site language from the Settings → Language tab).
|
||||
**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles.
|
||||
|
||||
**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor, Claude and Copilot), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles.
|
||||
|
||||
|  |  |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
@@ -43,41 +38,6 @@ The application interface now supports **English and French** via JSON locale bu
|
||||
- **App version control**: manage app version display (footer) from admin panel (no GitHub dependency)
|
||||
- **Player UI settings**: control player page admin status display
|
||||
|
||||
## Localization (i18n)
|
||||
|
||||
The app ships with a lightweight client-side localization system and two locales: **English** and **French**.
|
||||
|
||||
- Locale files live in:
|
||||
- `public/locales/en.json`
|
||||
- `public/locales/fr.json`
|
||||
- The i18n runtime is implemented in `public/js/localization.js` and exposed as `window.i18n`.
|
||||
- The admin can choose the **site language** (currently `en` or `fr`) from the **Admin → Settings → Language** tab. This value is stored in MongoDB and used by the client to pick the active locale.
|
||||
- Markup is localized via `data-i18n` attributes (and friends):
|
||||
- `data-i18n` → element text content
|
||||
- `data-i18n-placeholder` → input placeholders
|
||||
- `data-i18n-aria-label` → ARIA labels
|
||||
- `data-i18n-title` → `title` attributes
|
||||
- JavaScript code uses a small helper to translate keys with an English fallback, for example:
|
||||
|
||||
```js
|
||||
// inside public/js/* files
|
||||
function t(key, fallback, params) {
|
||||
if (window.i18n && typeof window.i18n.t === 'function') {
|
||||
const base = window.i18n.t(key, params || {});
|
||||
return base && base !== key ? base : (fallback || key);
|
||||
}
|
||||
return fallback || key;
|
||||
}
|
||||
|
||||
showResponse('success', t('admin.movies.updatedSuccessfully', 'Movie updated successfully.'));
|
||||
```
|
||||
|
||||
To add new text:
|
||||
|
||||
1. Add an English entry in `public/locales/en.json` (this is the base source of truth).
|
||||
2. Optionally add the corresponding French translation to `public/locales/fr.json` using the same key.
|
||||
3. In HTML, wire it with `data-i18n="your.key"`, or in JS call `t('your.key', 'English fallback')`.
|
||||
|
||||
## Setup
|
||||
Create a `.env` file at the project root (same folder as `package.json`).
|
||||
|
||||
@@ -106,38 +66,6 @@ Generate a good `JWT_SECRET`:
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## First-time admin setup
|
||||
|
||||
On a brand new database (no users with `role: 69`), the app exposes a simple first-time setup flow to create the initial admin account.
|
||||
|
||||
1. **Start the app**
|
||||
- Make sure `.env` has at least `MONGO_URI` and `JWT_SECRET`.
|
||||
- Run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
node index.js
|
||||
```
|
||||
|
||||
2. **Open the home page**
|
||||
- Visit `http://localhost:5050/` (or `http://localhost:${PORT}/` if you changed the port).
|
||||
- If no admin user exists yet, you'll see an **admin setup card** instead of the regular login form.
|
||||
- Fill in **name**, **email**, and **password**, then submit to create the first admin. The backend will:
|
||||
- create a `User` document with `role: 69` (admin), and
|
||||
- return a JWT so you're logged in immediately and redirected into the app.
|
||||
|
||||
3. **Access the admin panel**
|
||||
- Once logged in as admin, open the **Admin / Control** page (or navigate to `/control.html`).
|
||||
- From there you can:
|
||||
- Set the **active Oscar year** (Settings → Active year).
|
||||
- Configure the **Oscar date** for the countdown.
|
||||
- Adjust **points configuration** (points per movie / per correct pick).
|
||||
- Control **picks and version visibility**.
|
||||
- Choose the **site language** (Settings → Language → `en` / `fr`).
|
||||
- Manage movies, categories, winners, users, and the 100% completion modal.
|
||||
|
||||
After the first admin is created, the setup card disappears and the index page behaves as a normal login screen.
|
||||
|
||||
## Database (MongoDB)
|
||||
This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_URI`.
|
||||
|
||||
@@ -157,7 +85,7 @@ This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_UR
|
||||
The app also reads these environment variables (all optional):
|
||||
|
||||
```bash
|
||||
PORT=5050 # server port (default: 5050)
|
||||
PORT=5000 # server port (default: 5000)
|
||||
JWT_EXPIRES_IN=8h # JWT expiry (default: 8h)
|
||||
VIDEO_FILES_DIR=/abs/path/to/video # where server video files live (default: ./public/video)
|
||||
VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h)
|
||||
@@ -165,7 +93,7 @@ MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The server serves the UI from `public/` (open `http://localhost:5050` by default unless you override `PORT`).
|
||||
- The server serves the UI from `public/` (open `http://localhost:5000` by default).
|
||||
- If you use `video_file` in a movie record, it must be a **relative path** under `VIDEO_FILES_DIR` (the backend blocks traversal/absolute paths).
|
||||
|
||||
## Python video server (optional)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 717 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
+11
-28
@@ -3,31 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="changePassword.title">Oscar Pool - Change Password</title>
|
||||
<title>Pool Oscars - Changer le mot de passe</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -52,29 +34,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="Oscar Pool">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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" data-i18n="changePassword.newPassword">New password</label>
|
||||
<label for="new-password" class="form-label">Nouveau mot de passe</label>
|
||||
<input type="password" class="form-control" id="new-password" minlength="8" required>
|
||||
<div class="form-text" data-i18n="changePassword.minChars">Minimum 8 characters.</div>
|
||||
<div class="form-text">Minimum 8 caractères.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm-password" class="form-label" data-i18n="auth.confirmPassword">Confirm password</label>
|
||||
<label for="confirm-password" class="form-label">Confirmer le mot de passe</label>
|
||||
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" data-i18n="common.save">Save</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
|
||||
</form>
|
||||
|
||||
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3" data-i18n="nav.logout">Log out</button>
|
||||
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,3 +64,4 @@
|
||||
<script src="./js/app_change-password.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+23
-42
@@ -3,29 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="checklist.title">Oscar Pool - Checklist</title>
|
||||
<title>Pool Oscars - 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 {
|
||||
@@ -70,7 +52,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" data-i18n="common.loading">Loading…</span>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
@@ -89,16 +71,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -119,7 +101,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,9 +113,8 @@
|
||||
<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" 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 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>
|
||||
<div class="text-muted small"><span id="movies-per-day"></span></div>
|
||||
</div>
|
||||
@@ -149,14 +130,14 @@
|
||||
<table class="table table-striped table-hover align-middle movie-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th scope="col">Vu</th>
|
||||
<th scope="col">Titre</th>
|
||||
<th scope="col">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" data-i18n="common.loading">Loading…</td>
|
||||
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -173,15 +154,15 @@
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="uncheckConfirmModalLabel" data-i18n="checklist.confirmRemoval">Confirm removal</h5>
|
||||
<h5 class="modal-title" id="uncheckConfirmModalLabel">Confirmer le retrait</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="uncheck-confirm-text">Are you sure you want to remove <strong id="uncheckMovieTitle"></strong> from your list?</p>
|
||||
<p>Es-tu sûr de vouloir retirer <strong id="uncheckMovieTitle"></strong> de ta liste?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmUncheckBtn">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +173,7 @@
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="videoModalLabel" data-i18n="checklist.congratulations">Congratulations! very nice 🎉🎉🎉</h5>
|
||||
<h5 class="modal-title" id="videoModalLabel">Félicitations! very nice 🎉🎉🎉</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
@@ -201,7 +182,7 @@
|
||||
|
||||
<video id="rewardVideo" width="75%" controls class="d-none">
|
||||
<source id="rewardVideoSource" src="video/reward.mp4" type="video/mp4">
|
||||
<span data-i18n="checklist.browserNoVideo">Your browser does not support video.</span>
|
||||
Votre navigateur ne supporte pas la vidéo.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +191,7 @@
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
@@ -219,4 +200,4 @@
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+274
-326
File diff suppressed because it is too large
Load Diff
@@ -399,64 +399,6 @@ footer {
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
/* Smooth navbar transition while i18n loads
|
||||
- Keep navbar visible with a soft fade/slide 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;
|
||||
/* Light mode & default: muted appearance while i18n loads. */
|
||||
filter: blur(0.8px) saturate(0.97);
|
||||
opacity: 0.55;
|
||||
transform: translateY(-1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Light mode: keep navbar container visible to avoid layout jump. */
|
||||
html:not([data-theme="dark"]) body:not(.i18n-ready) nav.navbar,
|
||||
html:not([data-theme="dark"]) body:not(.i18n-ready) .navbar {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
filter: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Light mode: match page background to navbar during loading to avoid edge lines. */
|
||||
html:not([data-theme="dark"]) body:not(.i18n-ready) {
|
||||
background-color: var(--app-navbar);
|
||||
}
|
||||
|
||||
/* Dark mode: keep the original soft blur effect while loading
|
||||
so the navbar stays visible but its text is obscured. */
|
||||
html[data-theme="dark"] body:not(.i18n-ready) nav.navbar,
|
||||
html[data-theme="dark"] body:not(.i18n-ready) .navbar,
|
||||
html[data-theme="dark"] body:not(.i18n-ready) .navbar .navbar-brand,
|
||||
html[data-theme="dark"] body:not(.i18n-ready) .navbar .nav-link,
|
||||
html[data-theme="dark"] body:not(.i18n-ready) .navbar .navbar-text,
|
||||
html[data-theme="dark"] body:not(.i18n-ready) .navbar .btn {
|
||||
filter: blur(6px) saturate(0.9) grayscale(0.06);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@@ -835,34 +777,6 @@ body.i18n-ready .navbar .btn {
|
||||
border-bottom: 1px solid var(--app-navbar-border);
|
||||
}
|
||||
|
||||
/* Hide nav button text and show blur line while i18n loads */
|
||||
body:not(.i18n-ready) .navbar-custom .navbar-nav .nav-link.btn {
|
||||
color: transparent !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body:not(.i18n-ready) .navbar-custom .navbar-nav .nav-link.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70%;
|
||||
height: 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
/* Restore text after i18n loads */
|
||||
body.i18n-ready .navbar-custom .navbar-nav .nav-link.btn {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
body.i18n-ready .navbar-custom .navbar-nav .nav-link.btn::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Navbar: show current page state */
|
||||
.navbar-custom .navbar-nav .nav-link.btn.is-current {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
@@ -870,67 +784,23 @@ body.i18n-ready .navbar .btn {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Override btn-warning for active state */
|
||||
.navbar-custom .navbar-nav #admin-control-link.is-current {
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border-color: rgba(255, 255, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
.navbar-custom .navbar-nav .nav-link.btn.is-current:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-color: rgba(255, 255, 255, 0.65);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Override btn-warning hover state */
|
||||
.navbar-custom .navbar-nav #admin-control-link.is-current:hover {
|
||||
background: rgba(255, 255, 255, 0.22) !important;
|
||||
border-color: rgba(255, 255, 255, 0.65) !important;
|
||||
}
|
||||
|
||||
/* Admin button current state in light mode - override btn-warning */
|
||||
.navbar-custom .navbar-nav #admin-control-link.btn-warning.is-current,
|
||||
.navbar-custom .navbar-nav #admin-control-link.btn-warning.is-current:focus {
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
border-color: rgba(255, 255, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
.navbar-custom .navbar-nav #admin-control-link.btn-warning.is-current:hover,
|
||||
.navbar-custom .navbar-nav #admin-control-link.btn-warning.is-current:active {
|
||||
background: rgba(255, 255, 255, 0.22) !important;
|
||||
border-color: rgba(255, 255, 255, 0.65) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .navbar-custom .navbar-nav .nav-link.btn.is-current {
|
||||
background: rgba(79, 124, 255, 0.22);
|
||||
border-color: rgba(79, 124, 255, 0.58);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Dark mode: Admin button active state */
|
||||
html[data-theme="dark"] .navbar-custom .navbar-nav #admin-control-link.is-current {
|
||||
background: rgba(79, 124, 255, 0.22) !important;
|
||||
border-color: rgba(79, 124, 255, 0.58) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .navbar-custom .navbar-nav .nav-link.btn.is-current:hover {
|
||||
background: rgba(79, 124, 255, 0.28);
|
||||
border-color: rgba(79, 124, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Admin button current state in dark mode */
|
||||
html[data-theme="dark"] .navbar-custom .navbar-nav #admin-control-link.is-current {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .navbar-custom .navbar-nav #admin-control-link.is-current:hover {
|
||||
background-color: #0b5ed7;
|
||||
border-color: #0b5ed7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* The logo image has a bit of built-in left whitespace; nudge it back to align with page titles. */
|
||||
.navbar-custom .navbar-brand img {
|
||||
margin-left: -9px;
|
||||
@@ -1006,16 +876,6 @@ body.i18n-ready .navbar .btn {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Light mode navbar buttons */
|
||||
.navbar-custom .btn-outline-light {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.navbar-custom .btn-outline-light:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-outline-dark {
|
||||
color: var(--app-text);
|
||||
border-color: var(--app-border);
|
||||
|
||||
+18
-34
@@ -3,26 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="common.oscarPool">Oscar Pool</title>
|
||||
<title>Pool Oscars</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%;
|
||||
@@ -37,10 +22,9 @@
|
||||
}
|
||||
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -66,52 +50,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="Oscar Pool">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<div class="alert alert-info mb-3">
|
||||
<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>
|
||||
<strong>Première installation</strong><br>
|
||||
Aucun administrateur n'existe. Créez le premier compte admin pour commencer.
|
||||
</div>
|
||||
<form id="setup-form">
|
||||
<div class="mb-3">
|
||||
<label for="setup-name" class="form-label" data-i18n="auth.name">Name</label>
|
||||
<input type="text" class="form-control" id="setup-name" data-i18n-placeholder="auth.namePlaceholder" placeholder="Your name" required>
|
||||
<label for="setup-name" class="form-label">Nom</label>
|
||||
<input type="text" class="form-control" id="setup-name" placeholder="Votre nom" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-email" class="form-label" 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>
|
||||
<label for="setup-email" class="form-label">Courriel</label>
|
||||
<input type="email" class="form-control" id="setup-email" placeholder="admin@exemple.com" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-password" class="form-label" data-i18n="auth.password">Password</label>
|
||||
<label for="setup-password" class="form-label">Mot de passe</label>
|
||||
<input type="password" class="form-control" id="setup-password" minlength="6" required>
|
||||
<div class="form-text" data-i18n="auth.passwordMin" data-i18n-params='{"min": 6}'>Minimum 6 characters</div>
|
||||
<div class="form-text">Minimum 6 caractères</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="setup-password-confirm" class="form-label" data-i18n="auth.confirmPassword">Confirm password</label>
|
||||
<label for="setup-password-confirm" class="form-label">Confirmer le mot de passe</label>
|
||||
<input type="password" class="form-control" id="setup-password-confirm" minlength="6" required>
|
||||
</div>
|
||||
<div id="setup-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
<button type="submit" class="btn btn-warning w-100" data-i18n="auth.createAdminAccount">Create admin account</button>
|
||||
<button type="submit" class="btn btn-warning w-100">Créer le compte admin</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Normal login card -->
|
||||
<div id="login-card" class="card p-4" style="max-width: 400px; margin: auto;">
|
||||
<div class="text-center mb-4">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label" data-i18n="auth.email">Email</label>
|
||||
<label for="email" class="form-label">Courriel</label>
|
||||
<input type="email" class="form-control" id="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label" data-i18n="auth.password">Password</label>
|
||||
<label for="password" class="form-label">Mot de passe</label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" data-i18n="auth.login">Login</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Connexion</button>
|
||||
</form>
|
||||
<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>
|
||||
<p class="mt-3 text-center">Pas de compte? <a href="/register.html">Enregistrez-vous</a></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,20 +2,6 @@ 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;
|
||||
@@ -95,11 +81,11 @@ window.onload = function () {
|
||||
const confirmPassword = document.getElementById('confirm-password').value;
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
showError(t('auth.passwordMinLength', 'Password must be at least 8 characters.'));
|
||||
showError('Le mot de passe doit contenir au moins 8 caractères.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showError(t('auth.passwordsDoNotMatch', 'Passwords do not match'));
|
||||
showError('Les mots de passe ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,7 +116,7 @@ window.onload = function () {
|
||||
|
||||
window.location.href = '/movies.html';
|
||||
} catch (err) {
|
||||
showError(err.message || t('auth.networkError', 'Network error'));
|
||||
showError(err.message || 'Erreur réseau');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+22
-85
@@ -1,21 +1,7 @@
|
||||
// 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 || t('common.loading', 'Loading…'));
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
@@ -133,8 +119,8 @@ function createPageLoader(options = {}) {
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async function () {
|
||||
const pageLoader = createPageLoader({
|
||||
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…'
|
||||
title: 'Chargement de la checklist',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -147,7 +133,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="alert alert-danger my-2" role="alert">
|
||||
${message || t('common.errorLoading', 'Error loading.')}
|
||||
${message || 'Erreur lors du chargement.'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -155,8 +141,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
}
|
||||
|
||||
const DEFAULT_COMPLETION_MODAL = Object.freeze({
|
||||
// Default title: use the localized 100% modal title if available.
|
||||
title: t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉'),
|
||||
title: 'Félicitations! very nice 🎉🎉🎉',
|
||||
bodyText: '',
|
||||
videoSrc: 'video/reward.mp4',
|
||||
bodyHtml: ''
|
||||
@@ -195,12 +180,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const videoEl = document.getElementById('rewardVideo');
|
||||
const videoSourceEl = document.getElementById('rewardVideoSource');
|
||||
|
||||
const rawTitle = String(content?.title || '').trim();
|
||||
// If the stored value is the i18n key itself, translate it; otherwise
|
||||
// use the stored title, falling back to the localized default.
|
||||
const title = (!rawTitle || rawTitle === 'admin.modal100.defaultTitle')
|
||||
? t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉')
|
||||
: rawTitle;
|
||||
const title = String(content?.title || '').trim() || DEFAULT_COMPLETION_MODAL.title;
|
||||
const bodyText = String(content?.bodyText || '');
|
||||
const bodyHtml = sanitizeHtml(String(content?.bodyHtml || ''));
|
||||
const videoSrc = String(content?.videoSrc || '').trim();
|
||||
@@ -285,20 +265,16 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
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' });
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
|
||||
const currentDate = new Date();
|
||||
let timeDifference = fallbackDate - currentDate;
|
||||
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) {
|
||||
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
|
||||
timeLeftEl.textContent = daysLabel;
|
||||
}
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
const targetDate =
|
||||
@@ -307,17 +283,13 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
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' });
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
const now = new Date();
|
||||
timeDifference = targetDate - now;
|
||||
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
if (timeLeftEl) {
|
||||
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
|
||||
timeLeftEl.textContent = daysLabel;
|
||||
}
|
||||
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}).catch(() => {});
|
||||
|
||||
if (token) {
|
||||
@@ -361,12 +333,7 @@ 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) {
|
||||
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;
|
||||
}
|
||||
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
|
||||
updateProgressBar(watchedCount, totalCount, false);
|
||||
pageLoader.setProgress(32);
|
||||
return summary;
|
||||
@@ -374,7 +341,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
.catch(() => null);
|
||||
|
||||
// Fetch remaining data in parallel.
|
||||
pageLoader.setSubtitle(t('checklist.loadingMovies', 'Loading movies…'));
|
||||
pageLoader.setSubtitle('Chargement de la liste des films…');
|
||||
pageLoader.setProgress(26);
|
||||
const [completionModalContent, moviesRes] = await Promise.all([
|
||||
fetchCompletionModalContent(),
|
||||
@@ -405,8 +372,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
|
||||
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
|
||||
} catch (e) {
|
||||
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.');
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,12 +383,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const totalMoviesCount = movies.length;
|
||||
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||
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;
|
||||
}
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||
|
||||
function launchConfetti() {
|
||||
const duration = 3 * 1000; // 3 seconds
|
||||
@@ -560,22 +522,12 @@ 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';
|
||||
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;
|
||||
}
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
|
||||
updateProgressBar(updatedWatchedCount, totalMoviesCount);
|
||||
|
||||
const moviesLeft = totalMoviesCount - updatedWatchedCount;
|
||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||
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;
|
||||
}
|
||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,35 +562,20 @@ 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';
|
||||
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;
|
||||
}
|
||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
|
||||
updateProgressBar(updatedWatchedCount, totalMoviesCount);
|
||||
|
||||
// Recalculate and update movies per day
|
||||
const moviesLeft = totalMoviesCount - updatedWatchedCount;
|
||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||
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;
|
||||
}
|
||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const moviesLeft = totalMoviesCount - watchedMoviesCount;
|
||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||
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;
|
||||
}
|
||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
||||
|
||||
pageLoader.setSubtitle('Finalisation de l’affichage…');
|
||||
pageLoader.setProgress(92);
|
||||
|
||||
+165
-312
File diff suppressed because it is too large
Load Diff
@@ -86,12 +86,10 @@
|
||||
// Show button immediately based on cached value
|
||||
if (cachedValue.showPicksButton !== false) {
|
||||
picksButton.style.display = '';
|
||||
} else {
|
||||
picksButton.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
// Default to hiding while we fetch (new installs should start disabled)
|
||||
picksButton.style.display = 'none';
|
||||
// Default to showing while we fetch
|
||||
picksButton.style.display = '';
|
||||
}
|
||||
|
||||
// Fetch fresh value from server
|
||||
|
||||
+4
-38
@@ -2,39 +2,6 @@ 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);
|
||||
@@ -143,13 +110,13 @@ if (setupForm) {
|
||||
window.location.href = '/movies.html';
|
||||
} else {
|
||||
if (setupError) {
|
||||
setupError.textContent = data.message || t('auth.errorCreatingAccount', 'Error creating account');
|
||||
setupError.textContent = data.message || 'Erreur lors de la création du compte';
|
||||
setupError.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (setupError) {
|
||||
setupError.textContent = t('auth.networkError', 'Network error. Please try again.');
|
||||
setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
|
||||
setupError.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
@@ -180,8 +147,7 @@ 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().catch(() => ({}));
|
||||
const msg = translateApiMessage(error.messageKey, error.message, 'auth.loginFailed', 'Login failed');
|
||||
alert(msg);
|
||||
const error = await res.json();
|
||||
alert(`${error.message}`);
|
||||
}
|
||||
});
|
||||
+26
-78
@@ -1,21 +1,7 @@
|
||||
// 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 || t('common.loading', 'Loading…'));
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
@@ -161,8 +147,8 @@ async function waitForImagesIn(containerEl, onProgress) {
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async function () {
|
||||
const pageLoader = createPageLoader({
|
||||
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…'
|
||||
title: 'Chargement des films',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
function formatClock(seconds) {
|
||||
@@ -286,11 +272,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
|
||||
if (avgEl) {
|
||||
if (avg && count > 0) {
|
||||
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.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
|
||||
avgEl.classList.remove('movie-rating-sub--empty');
|
||||
} else {
|
||||
avgEl.textContent = '';
|
||||
@@ -353,12 +335,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (lastUpdatedIso) {
|
||||
const d = new Date(lastUpdatedIso);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
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();
|
||||
}
|
||||
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} else {
|
||||
lastUpdatedEl.textContent = '—';
|
||||
}
|
||||
@@ -370,16 +347,11 @@ 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) {
|
||||
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;
|
||||
}
|
||||
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
|
||||
}
|
||||
|
||||
const activeYear = await fetchActiveYear();
|
||||
document.title = `Pool Oscars (${activeYear}) - Movies`;
|
||||
document.title = `Pool Oscars (${activeYear}) - Films`;
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
@@ -389,18 +361,16 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
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' });
|
||||
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {
|
||||
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
|
||||
oscarDayMonthEl.textContent = '15 mars';
|
||||
}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = fallbackDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
|
||||
timeLeftEl.textContent = daysLabel;
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
|
||||
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
|
||||
@@ -410,16 +380,14 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
|
||||
if (oscarDayMonthEl) {
|
||||
try {
|
||||
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' });
|
||||
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
} catch (_) {}
|
||||
}
|
||||
if (timeLeftEl) {
|
||||
const currentDate = new Date();
|
||||
const timeDifference = targetDate - currentDate;
|
||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
|
||||
timeLeftEl.textContent = daysLabel;
|
||||
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -505,11 +473,8 @@ 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 = (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;
|
||||
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||
document.getElementById('watched-ratio').innerText = ratioText;
|
||||
|
||||
if (moviesList) moviesList.innerHTML = '';
|
||||
|
||||
@@ -536,11 +501,6 @@ 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) => {
|
||||
@@ -571,13 +531,10 @@ 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>${resumeText}</span>
|
||||
<span>Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span></span>
|
||||
<span>${pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
@@ -587,12 +544,9 @@ 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">
|
||||
${resumeText}
|
||||
Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -600,12 +554,6 @@ 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}
|
||||
@@ -618,7 +566,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
||||
movieDiv.innerHTML = `
|
||||
<div class="card h-100 shadow-sm">
|
||||
${watchedBadge}
|
||||
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
|
||||
${
|
||||
isClickable
|
||||
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
|
||||
@@ -639,26 +587,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${avgHtml ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${avgHtml ? avgHtml : ''}</div>
|
||||
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'}">
|
||||
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
|
||||
<span class="imdb-icon" aria-hidden="true">IMDb</span>
|
||||
</a>
|
||||
</div>
|
||||
<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 : ''}">
|
||||
<div class="movie-rating-stars" role="group" aria-label="Noter ce film" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
|
||||
${starsHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${noSourceHtml}
|
||||
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
|
||||
<div class="movie-info-box">
|
||||
<details class="movie-info-categories">
|
||||
<summary class="movie-info-summary">
|
||||
<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-title">Catégories nominées</span>
|
||||
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="movie-info-category">${movie.category}</div>
|
||||
@@ -667,7 +615,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
${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>` : ''}
|
||||
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||
${progressHtml}
|
||||
</div>
|
||||
</div>
|
||||
@@ -710,8 +658,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
});
|
||||
pageLoader.done();
|
||||
} catch (e) {
|
||||
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.'));
|
||||
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.');
|
||||
}
|
||||
|
||||
document.getElementById('log-off').addEventListener('click', function () {
|
||||
|
||||
+49
-74
@@ -2,9 +2,9 @@
|
||||
'use strict';
|
||||
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || t('common.loading', 'Loading…'));
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…'));
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
@@ -120,21 +120,6 @@
|
||||
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 = '/';
|
||||
@@ -213,7 +198,7 @@
|
||||
// Use active year if not set
|
||||
if (!currentYear) {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes picks`;
|
||||
}
|
||||
|
||||
if (pageLoader) pageLoader.setProgress(18);
|
||||
@@ -240,12 +225,10 @@
|
||||
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">
|
||||
${noCats}
|
||||
${adminHint}
|
||||
Aucune catégorie trouvée pour l'année ${currentYear}.
|
||||
Un administrateur doit d'abord importer les catégories.
|
||||
</div>
|
||||
`;
|
||||
if (pageLoader) pageLoader.done();
|
||||
@@ -260,7 +243,7 @@
|
||||
if (pageLoader) pageLoader.setProgress(85);
|
||||
} catch (err) {
|
||||
console.error('Error fetching categories:', err);
|
||||
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
|
||||
if (pageLoader) pageLoader.fail();
|
||||
}
|
||||
}
|
||||
@@ -304,7 +287,7 @@
|
||||
if (!container) return;
|
||||
|
||||
if (categories.length === 0) {
|
||||
container.innerHTML = `<div class="text-center py-4 text-muted">${t('picks.noCategoriesAvailable', 'No categories available')}</div>`;
|
||||
container.innerHTML = '<div class="text-center py-4 text-muted">Aucune catégorie disponible</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -314,9 +297,9 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>${t('picks.category', 'Category')}</th>
|
||||
<th style="width: 200px;">${t('picks.choice', 'Choice')}</th>
|
||||
<th>${t('picks.nominees', 'Nominees')}</th>
|
||||
<th>Catégorie</th>
|
||||
<th style="width: 200px;">Choix</th>
|
||||
<th>Nommés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -331,7 +314,7 @@
|
||||
<span class="selected-choice-badge badge bg-success">
|
||||
<span>${selectedNominee}</span>
|
||||
</span>
|
||||
` : `<span class="text-muted fst-italic">${t('picks.noChoice', 'No choice')}</span>`}
|
||||
` : '<span class="text-muted fst-italic">Aucun choix</span>'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@@ -403,7 +386,7 @@
|
||||
} catch (err) {
|
||||
console.error('Auto-save failed:', err);
|
||||
// Show error alert only on failure
|
||||
showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
@@ -430,7 +413,7 @@
|
||||
|
||||
// Skip confirmation dialog in silent mode (auto-save)
|
||||
if (!silent && picks.length < categories.length) {
|
||||
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 }))) {
|
||||
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -455,18 +438,18 @@
|
||||
|
||||
const data = await res.json();
|
||||
if (!silent) {
|
||||
showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success');
|
||||
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting picks:', err);
|
||||
showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('load-my-picks')?.addEventListener('click', () => {
|
||||
loadMyPicks();
|
||||
showAlert(t('picks.reloaded', 'Picks reloaded'), 'info');
|
||||
showAlert('Choix rechargés', 'info');
|
||||
});
|
||||
|
||||
// Winners & Scores functionality (admin only)
|
||||
@@ -497,7 +480,7 @@
|
||||
await fetchScores();
|
||||
} catch (err) {
|
||||
console.error('Error fetching winners categories:', err);
|
||||
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +489,7 @@
|
||||
if (!container) return;
|
||||
|
||||
if (winnersCategories.length === 0) {
|
||||
container.innerHTML = `<div class="alert alert-warning">${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}</div>`;
|
||||
container.innerHTML = '<div class="alert alert-warning">Aucune catégorie trouvée pour cette année.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -523,9 +506,9 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>${t('picks.categoryHeader', 'Category')}</th>
|
||||
<th style="width: 200px;">${t('picks.selectionHeader', 'Selection')}</th>
|
||||
<th>${t('picks.winnerHeader', 'Winner')}</th>
|
||||
<th>Catégorie</th>
|
||||
<th style="width: 200px;">Sélection</th>
|
||||
<th>Gagnant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -576,18 +559,13 @@
|
||||
throw new Error(error.message || 'Failed to update winner');
|
||||
}
|
||||
|
||||
showAlert(
|
||||
winnerName
|
||||
? t('picks.winnerMarked', 'Winner marked successfully')
|
||||
: t('picks.winnerDeleted', 'Winner deleted'),
|
||||
'success'
|
||||
);
|
||||
showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', '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(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -602,7 +580,7 @@
|
||||
const btn = document.getElementById('calculate-scores');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('picks.calculatingScores', 'Calculating scores…');
|
||||
btn.textContent = 'Calcul en cours...';
|
||||
}
|
||||
|
||||
const res = await fetch('/api/picks/calculate-scores', {
|
||||
@@ -620,7 +598,7 @@
|
||||
if (error.message && error.message.includes('No winners marked')) {
|
||||
if (!silent) {
|
||||
// Only show as info/warning, not error
|
||||
showAlert(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info');
|
||||
showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info');
|
||||
}
|
||||
// Still try to fetch existing scores
|
||||
await fetchScores();
|
||||
@@ -631,22 +609,19 @@
|
||||
|
||||
const data = await res.json();
|
||||
if (!silent) {
|
||||
showAlert(
|
||||
t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }),
|
||||
'success'
|
||||
);
|
||||
showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
|
||||
}
|
||||
await fetchScores();
|
||||
} catch (err) {
|
||||
console.error('Error calculating scores:', err);
|
||||
if (!silent) {
|
||||
showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur: ' + err.message, 'danger');
|
||||
}
|
||||
} finally {
|
||||
const btn = document.getElementById('calculate-scores');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('picks.calculateScores', 'Calculate scores');
|
||||
btn.textContent = 'Calculer les scores';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +653,7 @@
|
||||
console.error('Error fetching scores:', err);
|
||||
const container = document.getElementById('scores-container');
|
||||
if (container) {
|
||||
container.innerHTML = `<div class="text-danger">${t('picks.errorLoadingScores', 'Error loading scores.')}</div>`;
|
||||
container.innerHTML = '<div class="text-danger">Erreur lors du chargement des scores.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,7 +684,7 @@
|
||||
}).sort((a, b) => b.actualScore - a.actualScore);
|
||||
|
||||
if (validScores.length === 0) {
|
||||
container.innerHTML = `<div class="text-muted">${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}</div>`;
|
||||
container.innerHTML = '<div class="text-muted">Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,7 +694,7 @@
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 40px;">#</th>
|
||||
<th>${t('picks.userHeader', 'User')}</th>
|
||||
<th>Utilisateur</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>
|
||||
@@ -739,7 +714,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}">
|
||||
${t('picks.detailsButton', 'Details')}
|
||||
Détails
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -768,10 +743,10 @@
|
||||
const modalTitle = document.getElementById('userPicksModalLabel');
|
||||
const modalContent = document.getElementById('user-picks-modal-content');
|
||||
|
||||
modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName });
|
||||
modalTitle.textContent = `Choix de ${score.userName}`;
|
||||
|
||||
if (!score.pickDetails || score.pickDetails.length === 0) {
|
||||
modalContent.innerHTML = `<div class="text-muted">${t('picks.noDetailsAvailable', 'No details available.')}</div>`;
|
||||
modalContent.innerHTML = '<div class="text-muted">Aucun détail disponible.</div>';
|
||||
modal.show();
|
||||
return;
|
||||
}
|
||||
@@ -788,11 +763,11 @@
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="mb-1">${t('picks.summaryTitle', 'Summary')}</h6>
|
||||
<h6 class="mb-1">Résumé</h6>
|
||||
<div class="text-muted small">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
@@ -806,7 +781,7 @@
|
||||
<div class="mb-4">
|
||||
<h6 class="text-success mb-3">
|
||||
<span class="badge bg-success me-2">✓</span>
|
||||
${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
|
||||
Corrects (${correctPicks.length})
|
||||
</h6>
|
||||
<div class="list-group">
|
||||
${correctPicks.map(pick => `
|
||||
@@ -829,7 +804,7 @@
|
||||
<div class="mb-4">
|
||||
<h6 class="text-danger mb-3">
|
||||
<span class="badge bg-danger me-2">✗</span>
|
||||
${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
|
||||
Incorrects (${incorrectPicks.length})
|
||||
</h6>
|
||||
<div class="list-group">
|
||||
${incorrectPicks.map(pick => `
|
||||
@@ -839,13 +814,13 @@
|
||||
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
|
||||
<div class="mt-2">
|
||||
<div class="mb-1">
|
||||
<span class="badge bg-danger">✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}</span>
|
||||
<span class="badge bg-danger">✗ Choix: ${pick.selectedNominee}</span>
|
||||
</div>
|
||||
${pick.correctWinner ? `
|
||||
<div>
|
||||
<span class="badge bg-success">✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}</span>
|
||||
<span class="badge bg-success">✓ Gagnant: ${pick.correctWinner}</span>
|
||||
</div>
|
||||
` : `<div class="text-muted small">${t('picks.noWinnerMarkedShort', 'No winner marked')}</div>`}
|
||||
` : '<div class="text-muted small">Aucun gagnant marqué</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -857,7 +832,7 @@
|
||||
|
||||
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
|
||||
<div class="alert alert-info">
|
||||
${t('picks.noChoicesToShow', 'No picks to show.')}
|
||||
Aucun choix à afficher.
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
@@ -868,7 +843,7 @@
|
||||
// Event listeners for winners tab
|
||||
document.getElementById('refresh-winners')?.addEventListener('click', () => {
|
||||
fetchWinnersCategories();
|
||||
showAlert(t('picks.dataRefreshed', 'Data refreshed'), 'info');
|
||||
showAlert('Données rafraîchies', 'info');
|
||||
});
|
||||
|
||||
// Load scores when scores tab is shown
|
||||
@@ -892,19 +867,19 @@
|
||||
// Refresh scores button
|
||||
document.getElementById('refresh-scores')?.addEventListener('click', async () => {
|
||||
await fetchScores();
|
||||
showAlert(t('picks.scoresRefreshed', 'Scores refreshed'), 'info');
|
||||
showAlert('Scores rafraîchis', 'info');
|
||||
});
|
||||
|
||||
// Initial load
|
||||
window.addEventListener('DOMContentLoaded', async function() {
|
||||
const pageLoader = createPageLoader({
|
||||
title: t('picks.loadingTitle', 'Loading picks'),
|
||||
subtitle: t('picks.loadingSubtitle', 'Fetching data…')
|
||||
title: 'Chargement des picks',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
try {
|
||||
currentYear = await fetchActiveYear();
|
||||
document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
|
||||
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
|
||||
|
||||
pageLoader.setProgress(12);
|
||||
await fetchCategories(pageLoader);
|
||||
|
||||
+19
-33
@@ -7,20 +7,6 @@ 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;
|
||||
@@ -276,19 +262,19 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
|
||||
pendingRestoreTime = clamped;
|
||||
restoreInProgress = true;
|
||||
try { videoEl.currentTime = clamped; } catch (_) {}
|
||||
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) }) });
|
||||
lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
|
||||
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
|
||||
} else {
|
||||
pendingRestoreTime = null;
|
||||
restoreInProgress = false;
|
||||
lastSaveStatus = t('player.beginning', 'Start');
|
||||
setProgressUi({ state: 'info', text: t('player.beginning', 'Start'), showText: true, ariaText: t('player.playFromBeginning', 'Playing from the beginning') });
|
||||
lastSaveStatus = 'Début';
|
||||
setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRestore() {
|
||||
if (!restored) setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
|
||||
if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
|
||||
void tryRestoreProgress();
|
||||
}
|
||||
|
||||
@@ -329,18 +315,18 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
|
||||
} catch (_) {}
|
||||
|
||||
// Icon-only saving indicator (avoid noisy text updates).
|
||||
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: t('player.saving', 'Saving…') });
|
||||
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' });
|
||||
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 = 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');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
lastSaveStatus = t('player.saved', 'Saved at {time}.', { time: formatClock(snap.time) });
|
||||
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
|
||||
setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus });
|
||||
if (savedUiTimer) window.clearTimeout(savedUiTimer);
|
||||
savedUiTimer = window.setTimeout(() => {
|
||||
@@ -588,11 +574,11 @@ function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount,
|
||||
});
|
||||
|
||||
const parts = [];
|
||||
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);
|
||||
if (avg && count > 0) {
|
||||
parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`);
|
||||
} else {
|
||||
parts.push('Note utilisateurs: —');
|
||||
}
|
||||
metaEl.textContent = parts.join(' • ');
|
||||
}
|
||||
|
||||
@@ -656,7 +642,7 @@ function initUserRating({ movieId, token, initial } = {}) {
|
||||
|
||||
isSaving = true;
|
||||
buttons.forEach((b) => b.setAttribute('disabled', 'disabled'));
|
||||
metaEl.textContent = t('player.saving', 'Saving…');
|
||||
metaEl.textContent = 'Enregistrement…';
|
||||
|
||||
const shouldClear = currentRating && value === currentRating;
|
||||
const result = shouldClear
|
||||
@@ -1264,7 +1250,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) {
|
||||
}
|
||||
|
||||
window.onload = async function () {
|
||||
const pageLoader = createPageLoader({ title: t('player.loadingPlayer', 'Loading player…') });
|
||||
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
|
||||
|
||||
// Basic auth UX: require a valid local token (like the rest of the app).
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -1387,13 +1373,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: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
|
||||
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
|
||||
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: t('player.unableToIdentifyUser', "Unable to identify user for saving progress.") });
|
||||
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." });
|
||||
}
|
||||
} else {
|
||||
setProgressUi({ state: 'info', text: '—', showText: true });
|
||||
|
||||
@@ -2,22 +2,6 @@ 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);
|
||||
@@ -108,12 +92,7 @@ initPage();
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Show success message in the modal
|
||||
responseMessageElement.textContent = translateApiMessage(
|
||||
data?.messageKey,
|
||||
data?.message,
|
||||
'auth.registrationSuccess',
|
||||
'User registered successfully!'
|
||||
);
|
||||
responseMessageElement.textContent = data.message;
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
@@ -125,11 +104,7 @@ initPage();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
// Show error message in the modal
|
||||
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();
|
||||
responseMessageElement.textContent = `Error: ${error.message}`;
|
||||
|
||||
// Show the modal
|
||||
modal.show();
|
||||
|
||||
+3
-23
@@ -7,21 +7,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -126,14 +111,9 @@
|
||||
buttons.forEach((button) => {
|
||||
button.setAttribute('aria-checked', String(isDark));
|
||||
button.dataset.theme = theme;
|
||||
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());
|
||||
const label = isDark ? 'Mode sombre' : 'Mode clair';
|
||||
const sourceLabel = source === 'system' ? ' (système)' : '';
|
||||
button.setAttribute('title', `${label}${sourceLabel} — Shift+clic pour système`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+26
-50
@@ -1,7 +1,7 @@
|
||||
function createPageLoader(options = {}) {
|
||||
const title = String(options.title || t('common.loading', 'Loading…'));
|
||||
const title = String(options.title || 'Chargement…');
|
||||
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
|
||||
const subtitle = String(options.subtitle || t('stats.preparingPage', 'Preparing page…'));
|
||||
const subtitle = String(options.subtitle || 'Préparation de la page…');
|
||||
|
||||
let progress = 0;
|
||||
let removed = false;
|
||||
@@ -118,31 +118,10 @@ 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: t('stats.loadingStats', 'Loading statistics'),
|
||||
subtitle: t('stats.fetchingData', 'Fetching data…')
|
||||
title: 'Chargement des statistiques',
|
||||
subtitle: 'Récupération des données…'
|
||||
});
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -194,7 +173,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted">${t('stats.noWinnersDefined', 'No winners defined.')}</td>
|
||||
<td colspan="2" class="text-muted">Aucun gagnant défini.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
@@ -208,7 +187,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const arr = byYear.get(String(y)) || [];
|
||||
arr.push({
|
||||
year: y,
|
||||
name: w?.name || t('stats.deletedUser', '(deleted user)'),
|
||||
name: w?.name || '(utilisateur supprimé)',
|
||||
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
|
||||
});
|
||||
byYear.set(String(y), arr);
|
||||
@@ -236,7 +215,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (sorted.length > 1) {
|
||||
const small = document.createElement('div');
|
||||
small.className = 'text-muted small';
|
||||
small.textContent = t('stats.tie', 'tie');
|
||||
small.textContent = 'égalité';
|
||||
tdYear.appendChild(document.createElement('br'));
|
||||
tdYear.appendChild(small);
|
||||
}
|
||||
@@ -248,7 +227,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'fw-semibold';
|
||||
name.textContent = w?.name || t('stats.deletedUser', '(deleted user)');
|
||||
name.textContent = w?.name || '(utilisateur supprimé)';
|
||||
row.appendChild(name);
|
||||
|
||||
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
|
||||
@@ -275,7 +254,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (!completions || typeof completions !== 'object') {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">${t('stats.unableToLoadCompleters', 'Unable to load 100% completers.')}</td>
|
||||
<td colspan="3" class="text-muted">Impossible de charger les finisseurs 100%.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
@@ -288,7 +267,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (!years.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">${t('stats.noYearsFound', 'No years found.')}</td>
|
||||
<td colspan="3" class="text-muted">Aucune année trouvée.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
@@ -332,9 +311,9 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
let dateStr = '';
|
||||
if (u?.completedAt) {
|
||||
const date = new Date(u.completedAt);
|
||||
dateStr = ` (${date.toLocaleDateString(getLocale())})`;
|
||||
dateStr = ` (${date.toLocaleDateString('fr-CA')})`;
|
||||
}
|
||||
badge.textContent = `${rankPrefix}${u?.name || t('stats.unnamed', '(unnamed)')}${dateStr}`;
|
||||
badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`;
|
||||
wrap.appendChild(badge);
|
||||
});
|
||||
tdNames.appendChild(wrap);
|
||||
@@ -380,7 +359,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
try {
|
||||
pageLoader.setProgress(12);
|
||||
|
||||
// Modal UI (watched movies)
|
||||
// Modal UI (Films regardés)
|
||||
const moviesModalEl = document.getElementById('moviesModal');
|
||||
const movieGridEl = document.getElementById('movie-grid');
|
||||
const moviesSummaryEl = document.getElementById('movies-summary');
|
||||
@@ -404,7 +383,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
try {
|
||||
return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
} catch (_) {
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
@@ -434,9 +413,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : []));
|
||||
|
||||
const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0;
|
||||
moviesSummaryEl.textContent = total
|
||||
? t('stats.filmCount', '{count} movie{plural}', { count: total, plural: total > 1 ? 's' : '' })
|
||||
: t('stats.noFilms', '0 movies');
|
||||
moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film';
|
||||
|
||||
movieGridEl.innerHTML = '';
|
||||
if (!sorted.length) {
|
||||
@@ -475,7 +452,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 ? t('stats.openLink', 'Open link') + ': ' + title : t('stats.openPlayer', 'Open player') + ': ' + title);
|
||||
item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`);
|
||||
}
|
||||
|
||||
const poster = document.createElement('img');
|
||||
@@ -546,7 +523,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', (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb');
|
||||
imdbLink.setAttribute('aria-label', 'Voir sur IMDb');
|
||||
|
||||
const imdbIcon = document.createElement('span');
|
||||
imdbIcon.className = 'imdb-icon';
|
||||
@@ -578,7 +555,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (watchedLabel) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'text-muted';
|
||||
d.textContent = t('stats.watchedOn', 'Watched on {date}', { date: watchedLabel });
|
||||
d.textContent = `Vu le ${watchedLabel}`;
|
||||
meta.appendChild(d);
|
||||
}
|
||||
|
||||
@@ -591,7 +568,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
details.className = 'stats-movie-details';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = t('common.details', 'Details');
|
||||
summary.textContent = 'Détails';
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -615,8 +592,8 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
if (moviesModalTitleEl) {
|
||||
const count = currentModalMovies.length;
|
||||
moviesModalTitleEl.textContent = currentModalUserName
|
||||
? t('stats.watchedMoviesUser', 'Watched movies — {user} ({count})', { user: currentModalUserName, count: count })
|
||||
: t('stats.watchedMovies', 'Watched movies ({count})', { count: count });
|
||||
? `Films regardés — ${currentModalUserName} (${count})`
|
||||
: `Films regardés (${count})`;
|
||||
}
|
||||
|
||||
renderMoviesModal();
|
||||
@@ -642,10 +619,10 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
pageLoader.setProgress(20);
|
||||
const activeYear = await fetchActiveYear();
|
||||
if (activeYear) {
|
||||
document.title = `Pool Oscars (${activeYear}) - ${t('stats.pageTitle', 'User Statistics')}`;
|
||||
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
|
||||
// Keep the main heading clean; show the year in the "Classement" tab instead.
|
||||
const h2 = document.querySelector('h2');
|
||||
if (h2) h2.textContent = t('stats.pageTitle', 'User Statistics');
|
||||
if (h2) h2.textContent = 'Statistiques des utilisateurs';
|
||||
const badge = document.getElementById('stats-active-year-badge');
|
||||
if (badge) {
|
||||
badge.textContent = String(activeYear);
|
||||
@@ -679,7 +656,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const userTableBody = document.getElementById('user-table-body');
|
||||
|
||||
// Check visibility config for Bon picks column
|
||||
let showBonPicksColumn = false;
|
||||
let showBonPicksColumn = true;
|
||||
try {
|
||||
const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
|
||||
if (visibilityRes.ok) {
|
||||
@@ -717,8 +694,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
// Update Films column header with total
|
||||
const filmsHeader = document.getElementById('films-header');
|
||||
if (filmsHeader && totalMovies > 0) {
|
||||
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>`;
|
||||
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
|
||||
}
|
||||
|
||||
stats.forEach((userStat, index) => {
|
||||
@@ -775,7 +751,7 @@ window.addEventListener('DOMContentLoaded', async function () {
|
||||
const statsError = document.getElementById('stats-error');
|
||||
if (table) table.classList.add('d-none');
|
||||
if (statsError) {
|
||||
statsError.textContent = t('stats.unableToLoadRanking', 'Unable to load the ranking right now.');
|
||||
statsError.textContent = "Impossible de charger le classement pour le moment.";
|
||||
statsError.classList.remove('d-none');
|
||||
}
|
||||
// Still attempt to render these sections even if stats fail (best effort)
|
||||
|
||||
@@ -3,20 +3,8 @@
|
||||
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} - ${message}`;
|
||||
const footerText = `${data.date} - ${data.version} - ${data.message}`;
|
||||
document.getElementById('footer-text').textContent = footerText;
|
||||
} else {
|
||||
document.getElementById('footer-text').textContent = '';
|
||||
|
||||
@@ -41,27 +41,6 @@
|
||||
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;
|
||||
@@ -112,7 +91,7 @@
|
||||
renderPicks();
|
||||
} catch (err) {
|
||||
console.error('Error fetching picks:', err);
|
||||
showAlert(t('viewPicks.errorLoading', 'Error loading picks: {error}', { error: err.message }), 'danger');
|
||||
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +102,7 @@
|
||||
if (allPicks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
${t('viewPicks.noPicks', 'No user has submitted picks for year {year} yet.', { year: currentYear })}
|
||||
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -136,19 +115,9 @@
|
||||
});
|
||||
|
||||
container.innerHTML = allPicks.map(pick => {
|
||||
const userName = pick.userId?.name || t('viewPicks.unknownUser', 'Unknown user');
|
||||
const userName = pick.userId?.name || 'Utilisateur inconnu';
|
||||
const userEmail = pick.userId?.email || '';
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
|
||||
|
||||
// Create a map of picks by category
|
||||
const picksMap = new Map();
|
||||
@@ -172,7 +141,7 @@
|
||||
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
|
||||
<span class="text-muted">${selected}</span>
|
||||
</div>
|
||||
${isWinner ? `<span class="badge bg-success">${t('viewPicks.winnerBadge', 'Winner')}</span>` : ''}
|
||||
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -187,13 +156,13 @@
|
||||
<small class="text-muted">${userEmail}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<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>
|
||||
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
|
||||
<span class="badge bg-primary">${pick.picks.length} / ${categories.length} catégories</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${picksList || `<div class="text-muted">${t('viewPicks.noPicks2', 'No picks recorded')}</div>`}
|
||||
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -203,7 +172,7 @@
|
||||
// Event listeners
|
||||
document.getElementById('refresh-picks')?.addEventListener('click', () => {
|
||||
fetchAllPicks();
|
||||
showAlert(t('viewPicks.picksRefreshed', 'Picks refreshed'), 'info');
|
||||
showAlert('Choix rafraîchis', 'info');
|
||||
});
|
||||
|
||||
// Initial load
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
})();
|
||||
@@ -1,587 +0,0 @@
|
||||
{
|
||||
"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.modal100.defaultTitle": "Congratulations! very nice 🎉🎉🎉",
|
||||
|
||||
"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.noBackups": "No backups found.",
|
||||
"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)."
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
{
|
||||
"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.noBackups": "Aucune sauvegarde.",
|
||||
"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)."
|
||||
}
|
||||
+16
-35
@@ -3,29 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="movies.title">Oscar Pool - Movies</title>
|
||||
<title>Pool Oscars - 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 {
|
||||
@@ -57,7 +39,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" data-i18n="common.loading">Loading…</span>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
@@ -76,16 +58,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -106,7 +88,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,35 +100,34 @@
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<div class="text-muted small">
|
||||
<span data-i18n="movies.lastUpdated">Last movie update:</span> <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
|
||||
Dernière mise à jour des films: <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" 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 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>
|
||||
</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" data-i18n="movies.unwatchedOnly">Unwatched films only</label>
|
||||
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
|
||||
</div>
|
||||
<div class="text-muted small" data-i18n="movies.clickToPlay">Click the poster or title to open the player.</div>
|
||||
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</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" data-i18n="common.loading">Loading…</div>
|
||||
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
|
||||
+31
-48
@@ -3,29 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="picks.title">Oscar Pool - My picks</title>
|
||||
<title>Pool Oscars - Mes 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 {
|
||||
@@ -131,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" data-i18n="common.loading">Loading…</span>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
@@ -149,15 +131,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -178,7 +160,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,23 +168,23 @@
|
||||
|
||||
<div class="container mt-4 pb-5">
|
||||
<div id="alert-container"></div>
|
||||
|
||||
|
||||
<!-- 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" data-i18n="picks.myPicks">
|
||||
My 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">
|
||||
Mes 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" data-i18n="picks.scores">
|
||||
<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">
|
||||
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">
|
||||
<span data-i18n="picks.winners">Winners</span>
|
||||
<span class="badge bg-warning text-dark ms-1" data-i18n="picks.adminBadge">Admin</span>
|
||||
Gagnants
|
||||
<span class="badge bg-warning text-dark ms-1">Admin</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -212,16 +194,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"><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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary" data-i18n="picks.reload">Reload</button>
|
||||
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary">Recharger</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categories-container">
|
||||
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
|
||||
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,15 +211,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"><span data-i18n="picks.year">Year:</span> <span id="scores-year">—</span></span>
|
||||
<span class="fw-semibold">Année: <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" data-i18n="common.refresh">Refresh</button>
|
||||
<button type="button" id="refresh-scores" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scores-container">
|
||||
<div class="text-center py-3 text-muted small" data-i18n="picks.scoresCalculatedAfterWinners">Scores will be calculated after marking winners.</div>
|
||||
<div class="text-center py-3 text-muted small">Les scores seront calculés après avoir marqué les gagnants.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,16 +227,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"><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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
|
||||
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="winners-container">
|
||||
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
|
||||
<div class="text-center py-3 text-muted small">Chargement des catégories…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,16 +247,16 @@
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userPicksModalLabel" data-i18n="picks.choiceDetails">Choice details</h5>
|
||||
<h5 class="modal-title" id="userPicksModalLabel">Détails des choix</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" data-i18n="common.loading">Loading...</div>
|
||||
<div class="text-center py-4 text-muted">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.close">Close</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,7 +264,7 @@
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
@@ -292,3 +274,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+24
-41
@@ -5,29 +5,11 @@
|
||||
<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 data-i18n="player.title">Oscar Pool - Player</title>
|
||||
<title>Pool Oscars</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 {
|
||||
@@ -211,7 +193,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" data-i18n="common.loading">Loading…</span>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
@@ -230,15 +212,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -259,7 +241,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,7 +255,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" data-i18n="common.loading">Loading…</h2>
|
||||
<h2 id="movie-title" class="mb-1">Chargement…</h2>
|
||||
<div class="text-muted small">
|
||||
<span id="movie-category"></span><span id="movie-rating"></span>
|
||||
</div>
|
||||
@@ -281,20 +263,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" 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="user-rating" class="user-rating">
|
||||
<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 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>
|
||||
<div id="user-rating-meta" class="text-muted small">—</div>
|
||||
</div>
|
||||
@@ -302,12 +284,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" data-i18n="player.source">Source</span>
|
||||
<span class="badge bg-secondary">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" data-i18n="player.progress">Progress</span>
|
||||
<span class="badge bg-secondary">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>
|
||||
@@ -339,7 +321,7 @@
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer class="footer--player">
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
@@ -350,3 +332,4 @@
|
||||
<script src="./js/app_version.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+24
-40
@@ -3,37 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="auth.register">Oscar Pool - Register</title>
|
||||
<title>Pool Oscars - Enregistrement</title>
|
||||
<script src="./js/app_theme.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="./css/app.css" rel="stylesheet">
|
||||
<style>
|
||||
/* Hide translatable content until translations are ready - prevents flash during page navigation */
|
||||
body:not(.i18n-ready) [data-i18n] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease-in;
|
||||
}
|
||||
body.i18n-ready [data-i18n] {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Fallback: show content after 2 seconds even if JS fails */
|
||||
body:not(.i18n-ready) [data-i18n] {
|
||||
animation: show-after-delay 0.1s 2s forwards;
|
||||
}
|
||||
@keyframes show-after-delay {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
#dog {
|
||||
width: 30%;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
border-radius: 10%;
|
||||
}
|
||||
</style>
|
||||
<script src="./js/localization.js" defer></script>
|
||||
</head>
|
||||
<style>
|
||||
#dog {
|
||||
width: 30%;
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
border-radius: 10%;
|
||||
}
|
||||
</style>
|
||||
<body class="bg-light">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -59,41 +43,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="Oscar Pool">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong data-i18n="auth.setupRequired">Setup required</strong><br>
|
||||
<span data-i18n="auth.appMustBeConfigured">The application must first be configured by an administrator.</span>
|
||||
<strong>Configuration requise</strong><br>
|
||||
L'application doit d'abord être configurée par un administrateur.
|
||||
</div>
|
||||
<a href="/" class="btn btn-primary w-100" data-i18n="auth.goToSetup">Go to setup page</a>
|
||||
<a href="/" class="btn btn-primary w-100">Aller à la page de configuration</a>
|
||||
</div>
|
||||
|
||||
<!-- Normal registration card -->
|
||||
<div id="register-card" class="card p-4" style="max-width: 400px;">
|
||||
<div class="text-center mb-4">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
|
||||
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
|
||||
</div>
|
||||
<form id="register-form">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label" data-i18n="auth.name">Name</label>
|
||||
<label for="name" class="form-label">Nom</label>
|
||||
<input type="text" class="form-control" id="name" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label" data-i18n="auth.email">Email</label>
|
||||
<label for="email" class="form-label">Courriel</label>
|
||||
<input type="email" class="form-control" id="email" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label" data-i18n="auth.password">Password</label>
|
||||
<label for="password" class="form-label">Mot de passe</label>
|
||||
<input type="password" class="form-control" id="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="verifoof" class="form-label" data-i18n="auth.dogQuestion">What is Cath & Nath's dog's name?</label>
|
||||
<label for="verifoof" class="form-label">Le nom du chien à Cath & Nath?</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" data-i18n="auth.register">Register</button>
|
||||
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
|
||||
</form>
|
||||
<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>
|
||||
<p class="mt-3 text-center">Déjà un compte? <a href="/">Connectez-vous</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,15 +86,15 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="responseModalLabel" data-i18n="auth.registrationStatus">Registration status</h5>
|
||||
<h5 class="modal-title" id="responseModalLabel">Statut de l’enregistrement</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" 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+31
-49
@@ -3,29 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="stats.title">Oscar Pool - Statistics</title>
|
||||
<title>Pool Oscars - Statistiques des utilisateurs</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 +95,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" data-i18n="common.loading">Loading…</span>
|
||||
<span class="visually-hidden">Chargement…</span>
|
||||
<div class="page-loader-progress" aria-hidden="true">
|
||||
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
|
||||
</div>
|
||||
@@ -132,16 +114,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -162,7 +144,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,17 +155,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">
|
||||
<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>
|
||||
Classement
|
||||
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Année active"></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" data-i18n="stats.winners">
|
||||
Winners 🏆
|
||||
<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>
|
||||
</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" data-i18n="stats.completers">
|
||||
<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">
|
||||
100%
|
||||
</button>
|
||||
</li>
|
||||
@@ -198,11 +180,11 @@
|
||||
<table class="table table-striped table-hover align-middle user-table d-none">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table-body">
|
||||
@@ -217,13 +199,13 @@
|
||||
<table class="table table-sm table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 110px;" data-i18n="stats.year">Year</th>
|
||||
<th scope="col" data-i18n="stats.winnersColumn">Winner(s)</th>
|
||||
<th scope="col" style="width: 110px;">Année</th>
|
||||
<th scope="col">Gagnant(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" data-i18n="common.loading">Loading…</td>
|
||||
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -234,20 +216,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 data-i18n="stats.completersHelp">Users who have watched 100% (by year)</span>
|
||||
<span>Utilisateurs ayant terminé 100% (par année)</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;" 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>
|
||||
<th scope="col" style="width: 110px;">Année</th>
|
||||
<th scope="col" style="width: 140px;">Total films</th>
|
||||
<th scope="col">Noms</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" data-i18n="common.loading">Loading…</td>
|
||||
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -261,14 +243,14 @@
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="moviesModalLabel" data-i18n="stats.moviesWatched">Movies watched</h5>
|
||||
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</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" data-i18n="stats.noMoviesToShow">No movies to show.</div>
|
||||
<div id="movies-empty" class="text-muted d-none">Aucun film à afficher.</div>
|
||||
<div id="movie-grid" class="stats-movies-grid" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,7 +260,7 @@
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
@@ -287,4 +269,4 @@
|
||||
<script src="./js/app_version.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+19
-36
@@ -3,29 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title data-i18n="viewPicks.title">Oscar Pool - View All Picks</title>
|
||||
<title>Pool Oscars - Voir Tous les Choix</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 {
|
||||
@@ -76,16 +58,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" 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>
|
||||
<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>
|
||||
</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" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" aria-label="Basculer le mode sombre">
|
||||
<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>
|
||||
@@ -106,44 +88,44 @@
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
|
||||
<button id="log-off" class="btn btn-danger">Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4 pb-5">
|
||||
<h2 data-i18n="viewPicks.title">View All Picks</h2>
|
||||
|
||||
<h2>Voir Tous les Choix</h2>
|
||||
|
||||
<div id="alert-container"></div>
|
||||
|
||||
|
||||
<div class="card shadow-sm mt-3 mb-4">
|
||||
<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"><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 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>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" id="refresh-picks" class="btn btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
|
||||
<button type="button" id="refresh-picks" class="btn btn-outline-secondary">Rafraîchir</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted small">
|
||||
<span id="users-count">0</span> <span data-i18n="viewPicks.usersSubmitted" data-i18n-params='{"count": ""}'>user(s) have submitted their picks</span>
|
||||
<span id="users-count">0</span> utilisateur(s) ont soumis leurs choix
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="picks-container">
|
||||
<div class="text-center py-4 text-muted" data-i18n="viewPicks.loadingPicks">Loading picks…</div>
|
||||
<div class="text-center py-4 text-muted">Chargement des choix…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<footer>
|
||||
<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-copy">Oscar Pool <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
|
||||
<p id="footer-text"></p>
|
||||
</footer>
|
||||
|
||||
@@ -153,3 +135,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+3
-53
@@ -16,7 +16,6 @@ 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");
|
||||
|
||||
@@ -39,14 +38,10 @@ const DEFAULT_POINTS_CONFIG = Object.freeze({
|
||||
});
|
||||
|
||||
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
|
||||
showPicksButton: false,
|
||||
showBonPicksColumn: false,
|
||||
showVersionTab: true,
|
||||
showPicksButton: true,
|
||||
showBonPicksColumn: 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);
|
||||
@@ -777,8 +772,7 @@ 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;
|
||||
const showVersionTab = typeof v.showVersionTab === "boolean" ? v.showVersionTab : DEFAULT_VISIBILITY_CONFIG.showVersionTab;
|
||||
return { showPicksButton, showBonPicksColumn, showVersionTab };
|
||||
return { showPicksButton, showBonPicksColumn };
|
||||
}
|
||||
|
||||
// Public: get points configuration
|
||||
@@ -857,49 +851,5 @@ 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;
|
||||
|
||||
|
||||
+7
-24
@@ -501,10 +501,7 @@ router.post("/register", async (req, res) => {
|
||||
throw new Error("Le nom du chien doit être défini");
|
||||
}
|
||||
if (!registerVerification()) {
|
||||
return res.status(400).json({
|
||||
messageKey: 'auth.dogAnswerIncorrect',
|
||||
message: 'Incorrect dog name.',
|
||||
});
|
||||
return res.status(400).json({ message: "Mauvais nom de chien" });
|
||||
}
|
||||
|
||||
const existingUser = await User.findOne({ email: emailNorm });
|
||||
@@ -518,10 +515,7 @@ 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({
|
||||
messageKey: 'auth.registrationSuccess',
|
||||
message: 'User registered successfully!',
|
||||
});
|
||||
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||
}
|
||||
@@ -533,30 +527,19 @@ router.post("/login", async (req, res) => {
|
||||
|
||||
try {
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
messageKey: 'auth.userNotFound',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
|
||||
|
||||
// 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({
|
||||
messageKey: 'auth.tempPasswordExpired',
|
||||
message: 'Temporary password expired. Ask an admin to reset your password.',
|
||||
message: "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({
|
||||
messageKey: 'auth.invalidCredentials',
|
||||
message: 'Invalid credentials',
|
||||
});
|
||||
}
|
||||
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
|
||||
|
||||
console.log("[debug] " + user.name + " logged in")
|
||||
|
||||
@@ -630,7 +613,7 @@ router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
|
||||
await user.save();
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Temporary password generated. The user must change it on next login.",
|
||||
message: "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
|
||||
tempPassword,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
user: { id: String(user._id), name: user.name, email: user.email },
|
||||
@@ -714,7 +697,7 @@ router.post("/admin/create", verifyToken, async (req, res) => {
|
||||
await user.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: "User created. Temporary password generated.",
|
||||
message: "Utilisateur créé. Mot de passe temporaire généré.",
|
||||
tempPassword,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
|
||||
|
||||
+2
-4
@@ -79,8 +79,7 @@ router.get("/", async (req, res) => {
|
||||
if (!active) {
|
||||
return res.status(200).json({
|
||||
version: getPackageVersion(),
|
||||
messageKey: 'footer.versionNotConfigured',
|
||||
message: 'Version not configured (set it in Admin → Version).',
|
||||
message: "Version non configurée (définis-la dans Admin → Version).",
|
||||
author: "",
|
||||
date: nowFormatted(),
|
||||
configured: false,
|
||||
@@ -104,8 +103,7 @@ router.get("/", async (req, res) => {
|
||||
// If DB is down, still return something useful.
|
||||
return res.status(200).json({
|
||||
version: getPackageVersion(),
|
||||
messageKey: 'footer.versionUnavailable',
|
||||
message: 'Version unavailable (DB).',
|
||||
message: "Version indisponible (DB).",
|
||||
author: "",
|
||||
date: nowFormatted(),
|
||||
configured: false,
|
||||
|
||||
Reference in New Issue
Block a user