From 4308d6babf896e468e55cb105a6efecc42e4909e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 1 Feb 2026 20:13:54 -0500 Subject: [PATCH 01/10] Implement en/fr localization across admin and user UIs Introduce client-side localization helper and en/fr locale bundles. Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text. Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations). Update .gitignore to ignore editor metadata and localization helper artifacts. --- .gitignore | 16 +- public/change-password.html | 39 +- public/checklist.html | 65 ++-- public/control.html | 600 +++++++++++++++++-------------- public/css/app.css | 29 ++ public/index.html | 52 ++- public/js/app_change-password.js | 20 +- public/js/app_checklist.js | 99 +++-- public/js/app_control.js | 474 +++++++++++++++--------- public/js/app_header_boot.js | 6 +- public/js/app_index.js | 42 ++- public/js/app_movies.js | 102 ++++-- public/js/app_picks.js | 123 ++++--- public/js/app_player.js | 50 ++- public/js/app_register.js | 29 +- public/js/app_theme.js | 26 +- public/js/app_user-stat.js | 76 ++-- public/js/app_version.js | 14 +- public/js/app_view-picks.js | 49 ++- public/js/localization.js | 306 ++++++++++++++++ public/locales/en.json | 584 ++++++++++++++++++++++++++++++ public/locales/fr.json | 586 ++++++++++++++++++++++++++++++ public/movies.html | 51 ++- public/picks.html | 79 ++-- public/player.html | 65 ++-- public/register.html | 64 ++-- public/user-stat.html | 80 +++-- public/view-picks.html | 55 ++- routes/settings.js | 56 ++- routes/users.js | 31 +- routes/version.js | 6 +- 31 files changed, 3063 insertions(+), 811 deletions(-) create mode 100644 public/js/localization.js create mode 100644 public/locales/en.json create mode 100644 public/locales/fr.json diff --git a/.gitignore b/.gitignore index 7fddb09..b633661 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,13 @@ eng.traineddata Thumbs.db desktop.ini +# VS Code project settings +.vscode/ + +# AI assistant/editor metadata +.claude/ +.cursor/ + # Python cache files __pycache__/ *.pyc @@ -173,4 +180,11 @@ __pycache__/ .Python # Temporary scripts -temp/ \ No newline at end of file +temp/ + +# Localization helper artifacts (not part of app) +BEFORE_AFTER_COMPARISON.md +LOCALIZATION_COMPLETE.md +LOCALIZATION_PROGRESS.md +VERIFICATION_REPORT.md +localize_remaining.py \ No newline at end of file diff --git a/public/change-password.html b/public/change-password.html index 7a2b011..c4aa2ef 100644 --- a/public/change-password.html +++ b/public/change-password.html @@ -3,13 +3,31 @@ - Pool Oscars - Changer le mot de passe + Oscar Pool - Change Password + + - + - + @@ -64,4 +82,3 @@ - diff --git a/public/checklist.html b/public/checklist.html index 0636842..0f7de60 100644 --- a/public/checklist.html +++ b/public/checklist.html @@ -3,11 +3,29 @@ - Pool Oscars - Checklist + Oscar Pool - Checklist + + + + - +
- Pool Oscars + Oscar Pool
- +
- +
- +
-

Pas de compte? Enregistrez-vous

+

No account? Sign up

diff --git a/public/js/app_change-password.js b/public/js/app_change-password.js index c8aa0f0..c053820 100644 --- a/public/js/app_change-password.js +++ b/public/js/app_change-password.js @@ -2,6 +2,20 @@ function decodeJwt(token) { return JSON.parse(atob(token.split('.')[1])); } +// i18n helper function +function t(key, fallback = '', params = {}) { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params); + } + let result = fallback; + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + result = result.replace(`{${k}}`, String(params[k])); + }); + } + return result; +} + function isTokenExpired(decoded) { const currentTime = Math.floor(Date.now() / 1000); return !!decoded.exp && decoded.exp < currentTime; @@ -81,11 +95,11 @@ window.onload = function () { const confirmPassword = document.getElementById('confirm-password').value; if (newPassword.length < 8) { - showError('Le mot de passe doit contenir au moins 8 caractères.'); + showError(t('auth.passwordMinLength', 'Password must be at least 8 characters.')); return; } if (newPassword !== confirmPassword) { - showError('Les mots de passe ne correspondent pas.'); + showError(t('auth.passwordsDoNotMatch', 'Passwords do not match')); return; } @@ -116,7 +130,7 @@ window.onload = function () { window.location.href = '/movies.html'; } catch (err) { - showError(err.message || 'Erreur réseau'); + showError(err.message || t('auth.networkError', 'Network error')); } }); }; diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index 6d7db09..6ac7911 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -1,7 +1,21 @@ +// i18n helper function +function t(key, fallback = '', params = {}) { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params); + } + let result = fallback; + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + result = result.replace(`{${k}}`, String(params[k])); + }); + } + return result; +} + function createPageLoader(options = {}) { - const title = String(options.title || 'Chargement…'); + const title = String(options.title || t('common.loading', 'Loading…')); // Subtitle kept for backwards-compat with callers, but we no longer render text in the UI. - const subtitle = String(options.subtitle || 'Préparation de la page…'); + const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…')); let progress = 0; let removed = false; @@ -119,8 +133,8 @@ function createPageLoader(options = {}) { window.addEventListener('DOMContentLoaded', async function () { const pageLoader = createPageLoader({ - title: 'Chargement de la checklist', - subtitle: 'Récupération des données…' + title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.loadingChecklist') : 'Loading checklist', + subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…' }); const token = localStorage.getItem('auth_token'); @@ -133,7 +147,7 @@ window.addEventListener('DOMContentLoaded', async function () { @@ -141,7 +155,7 @@ window.addEventListener('DOMContentLoaded', async function () { } const DEFAULT_COMPLETION_MODAL = Object.freeze({ - title: 'Félicitations! very nice 🎉🎉🎉', + title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.congratulations') : 'Congratulations! very nice 🎉🎉🎉', bodyText: '', videoSrc: 'video/reward.mp4', bodyHtml: '' @@ -265,16 +279,20 @@ window.addEventListener('DOMContentLoaded', async function () { const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`); if (oscarDayMonthEl) { try { - oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en'); + oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); } catch (_) { - oscarDayMonthEl.textContent = '15 mars'; + oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15'; } } const currentDate = new Date(); let timeDifference = fallbackDate - currentDate; let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`; + if (timeLeftEl) { + const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`; + timeLeftEl.textContent = daysLabel; + } fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => { const targetDate = @@ -283,13 +301,17 @@ window.addEventListener('DOMContentLoaded', async function () { if (oscarDayMonthEl) { try { - oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en'); + oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); } catch (_) {} } const now = new Date(); timeDifference = targetDate - now; daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`; + if (timeLeftEl) { + const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`; + timeLeftEl.textContent = daysLabel; + } }).catch(() => {}); if (token) { @@ -333,7 +355,12 @@ window.addEventListener('DOMContentLoaded', async function () { const totalCount = Number(summary?.totalMoviesCount) || 0; const watchedCount = Number(summary?.watchedMoviesCount) || 0; const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0'; - if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`; + if (watchedRatioEl) { + const txt = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: watchedCount, total: totalCount, percent: ratioPct }) + : `Watched: ${watchedCount} / ${totalCount} (${ratioPct}%)`; + watchedRatioEl.innerText = txt; + } updateProgressBar(watchedCount, totalCount, false); pageLoader.setProgress(32); return summary; @@ -341,7 +368,7 @@ window.addEventListener('DOMContentLoaded', async function () { .catch(() => null); // Fetch remaining data in parallel. - pageLoader.setSubtitle('Chargement de la liste des films…'); + pageLoader.setSubtitle(t('checklist.loadingMovies', 'Loading movies…')); pageLoader.setProgress(26); const [completionModalContent, moviesRes] = await Promise.all([ fetchCompletionModalContent(), @@ -372,8 +399,8 @@ window.addEventListener('DOMContentLoaded', async function () { if (progressBarEl) progressBarEl.removeAttribute('aria-busy'); } catch (e) { - showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.'); - pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.'); + showTableError((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoad') : 'Unable to load checklist. Please try again later.'); + pageLoader.fail((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoadNetwork') : 'Unable to load checklist. Check your connection and try again.'); return; } @@ -383,7 +410,12 @@ window.addEventListener('DOMContentLoaded', async function () { const totalMoviesCount = movies.length; const watchedMoviesCount = watchedMoviesInYear.length; const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0'; - document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`; + if (document.getElementById('watched-ratio')) { + const txt = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct }) + : `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`; + document.getElementById('watched-ratio').innerText = txt; + } function launchConfetti() { const duration = 3 * 1000; // 3 seconds @@ -522,12 +554,22 @@ window.addEventListener('DOMContentLoaded', async function () { watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id)); const updatedWatchedCount = watchedMoviesInYear.length; const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0'; - document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`; + if (document.getElementById('watched-ratio')) { + const updatedTxt = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct }) + : `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`; + document.getElementById('watched-ratio').innerText = updatedTxt; + } updateProgressBar(updatedWatchedCount, totalMoviesCount); const moviesLeft = totalMoviesCount - updatedWatchedCount; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; - document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; + if (document.getElementById('movies-per-day')) { + const perDayText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay }) + : `Movies to watch per day: ${moviesPerDay}`; + document.getElementById('movies-per-day').textContent = perDayText; + } } }); @@ -562,20 +604,35 @@ window.addEventListener('DOMContentLoaded', async function () { watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id)); const updatedWatchedCount = watchedMoviesInYear.length; const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0'; - document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`; + if (document.getElementById('watched-ratio')) { + const updatedTxt = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct }) + : `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`; + document.getElementById('watched-ratio').innerText = updatedTxt; + } updateProgressBar(updatedWatchedCount, totalMoviesCount); // Recalculate and update movies per day const moviesLeft = totalMoviesCount - updatedWatchedCount; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; - document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; + if (document.getElementById('movies-per-day')) { + const perDayText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay }) + : `Movies to watch per day: ${moviesPerDay}`; + document.getElementById('movies-per-day').textContent = perDayText; + } } }); }); const moviesLeft = totalMoviesCount - watchedMoviesCount; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; - document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; + if (document.getElementById('movies-per-day')) { + const perDayText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay }) + : `Movies to watch per day: ${moviesPerDay}`; + document.getElementById('movies-per-day').textContent = perDayText; + } pageLoader.setSubtitle('Finalisation de l’affichage…'); pageLoader.setProgress(92); diff --git a/public/js/app_control.js b/public/js/app_control.js index b5d1a87..095d1d6 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -139,7 +139,7 @@ window.onload = async function () { let appVersionState = { active: null, versions: [] }; const DEFAULT_COMPLETION_MODAL = Object.freeze({ - title: 'Félicitations! very nice 🎉🎉🎉', + title: t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉'), bodyText: '', videoSrc: 'video/reward.mp4', bodyHtml: '', @@ -150,6 +150,23 @@ window.onload = async function () { showProgress: true, }); + // Small translation helper: prefers window.i18n.t when available, + // falls back to the provided fallback string and does simple {name}/{year} interpolation. + 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 showResponse(kind, message) { responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger'); @@ -283,7 +300,7 @@ window.onload = async function () { } catch (err) { // Keep defaults so admin can still set something. setPlayerUiForm(DEFAULT_PLAYER_UI); - showResponse('warning', err.message || 'Impossible de charger les réglages du lecteur.'); + showResponse('warning', err.message || t('admin.player.errorLoading', 'Unable to load player settings.')); } } @@ -451,7 +468,7 @@ window.onload = async function () { `` + `
` + `${activateBtn}` + - `` + + `` + `
` + `` ); @@ -472,9 +489,9 @@ window.onload = async function () { appVersionLoadedOnce = true; renderAppVersionUI(); await refreshAppVersionPreview(); - setAppVersionAlert('success', 'Version active mise à jour.'); + setAppVersionAlert('success', t('admin.versions.updated', 'Version updated.')); } catch (err) { - setAppVersionAlert('danger', err.message || 'Erreur réseau'); + setAppVersionAlert('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -486,7 +503,7 @@ window.onload = async function () { btn.addEventListener('click', async () => { const id = btn.getAttribute('data-app-version-delete'); if (!id) return; - const ok = window.confirm('Supprimer cette version ?'); + const ok = window.confirm(t('admin.version.confirmDelete','Delete this version?')); if (!ok) return; btn.disabled = true; const old = btn.textContent; @@ -497,9 +514,9 @@ window.onload = async function () { appVersionLoadedOnce = true; renderAppVersionUI(); await refreshAppVersionPreview(); - setAppVersionAlert('success', 'Version supprimée.'); + setAppVersionAlert('success', t('admin.versions.deleted', 'Version deleted.')); } catch (err) { - setAppVersionAlert('danger', err.message || 'Erreur réseau'); + setAppVersionAlert('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -523,7 +540,7 @@ window.onload = async function () { appVersionState = { active: null, versions: [] }; renderAppVersionUI(); await refreshAppVersionPreview(); - setAppVersionAlert('danger', err.message || 'Erreur réseau'); + setAppVersionAlert('danger', err.message || 'Network error'); } } @@ -773,7 +790,7 @@ window.onload = async function () { const badges = []; if (u?.admin) badges.push({ text: 'Admin', cls: 'bg-warning text-dark' }); - if (u?.mustChangePassword) badges.push({ text: 'Reset MDP', cls: 'bg-danger' }); + if (u?.mustChangePassword) badges.push({ text: t('admin.users.resetPassword','Reset MDP'), cls: 'bg-danger' }); if (!badges.length) { const span = document.createElement('span'); @@ -798,18 +815,20 @@ window.onload = async function () { const resetBtn = document.createElement('button'); resetBtn.type = 'button'; resetBtn.className = 'btn btn-outline-danger btn-sm me-1'; - resetBtn.textContent = 'Reset MDP'; + resetBtn.textContent = t('admin.users.resetPassword','Reset MDP'); resetBtn.addEventListener('click', () => openResetUserModal(u)); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'btn btn-outline-danger btn-sm'; - deleteBtn.textContent = 'Supprimer'; + deleteBtn.textContent = t('common.delete','Delete'); const isSelf = String(u?.id || '') && String(u.id) === String(decoded?.id || ''); if (isSelf || u?.admin) { deleteBtn.disabled = true; - deleteBtn.title = isSelf ? 'Impossible de supprimer ton propre compte' : 'Suppression des admins désactivée'; + deleteBtn.title = isSelf + ? t('admin.users.cannotDeleteSelf','You cannot delete your own account from here.') + : t('admin.users.adminDeleteDisabled','Deleting admin accounts is disabled via this interface.'); } deleteBtn.addEventListener('click', () => openDeleteUserModal(u)); @@ -855,14 +874,14 @@ window.onload = async function () { users.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' })); renderAdminUsers(users); } catch (err) { - adminUsersBody.innerHTML = `${err.message || 'Erreur réseau'}`; + adminUsersBody.innerHTML = `${err.message || t('common.networkError', 'Network error')}`; setUsersCount(null); } } function parseYear(value) { const n = Number(value); - if (!Number.isInteger(n)) return null; + if (!Number.isInteger(n)) return null; // Ensure the year is an integer if (n < 1900 || n > 3000) return null; return n; } @@ -922,7 +941,9 @@ window.onload = async function () { const lines = years.map((y) => { const key = String(y); const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null; - const date = (typeof val === 'string' && isIsoDateString(val)) ? val : '— (défaut: 03-15)'; + const date = (typeof val === 'string' && isIsoDateString(val)) + ? val + : t('admin.settings.oscarDateDefaultShort', '— (default: 03-15)'); return `${key}: ${date}`; }); @@ -932,7 +953,7 @@ window.onload = async function () { function applyOscarDateSelection() { if (!oscarDateYearSelect || !oscarDateValueInput) return; - const y = parseYear(oscarDateYearSelect.value); + const y = parseYear(oscarDateYearSelect.value); // Parse the selected year if (!y) { oscarDateValueInput.value = ''; return; @@ -944,7 +965,7 @@ window.onload = async function () { async function fetchWinners() { const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-store' }); - const data = await res.json().catch(() => ({})); + const data = await res.json().catch(() => ({})); // Handle JSON parsing errors if (!res.ok) { const msg = data.message || data.error || `Erreur (${res.status})`; throw new Error(msg); @@ -1017,7 +1038,7 @@ window.onload = async function () { if (!years.length) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'Aucune année (ajoute un film)'; + opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)'); opt.disabled = true; opt.selected = true; winnerYearSelect.appendChild(opt); @@ -1093,19 +1114,19 @@ window.onload = async function () { } const rows = sorted.map((w) => { - const name = escapeHtml(w?.name || '(utilisateur supprimé)'); + const name = escapeHtml(w?.name || t('stats.deletedUser', '(deleted user)')); const pts = w?.points === null || w?.points === undefined ? null : Number(w.points); const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` — ${escapeHtml(String(Math.round(pts)))} pts`; const uid = escapeHtml(String(w?.userId || '').trim()); return ( `
` + `
${name}${ptsLabel}
` + - `` + + `` + `
` ); }).join(''); - const tieLabel = sorted.length > 1 ? ' (égalité)' : ''; + const tieLabel = sorted.length > 1 ? ` ${t('admin.winners.tie', '(tie)')}` : ''; return `
${escapeHtml(String(y))}${tieLabel}
@@ -1129,9 +1150,9 @@ window.onload = async function () { try { await removeWinner(yy, uid); await loadWinners({ force: true }); - showResponse('success', `Gagnant retiré pour ${yy}.`); + showResponse('success', t('admin.winners.removedFor', 'Winner removed for {year}.', { year: yy })); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -1259,7 +1280,7 @@ window.onload = async function () { completionModalReloadBtn.textContent = 'Chargement...'; try { await loadCompletionModalIntoUi(); - showResponse('success', 'Contenu rechargé.'); + showResponse('success', t('admin.content.reloaded', 'Content reloaded.')); } catch (_) { // loadCompletionModalIntoUi already handles messaging } finally { @@ -1273,16 +1294,16 @@ window.onload = async function () { completionModalSaveBtn.addEventListener('click', async () => { completionModalSaveBtn.disabled = true; const oldText = completionModalSaveBtn.textContent; - completionModalSaveBtn.textContent = 'Enregistrement...'; + completionModalSaveBtn.textContent = t('common.saving', 'Saving...'); try { const value = getCompletionModalFormValue(); // send raw HTML; server sanitizes/caps it const saved = await saveCompletionModal(value); setCompletionModalForm(saved); updateCompletionModalPreview(getCompletionModalFormValue()); - showResponse('success', 'Modal 100% enregistré.'); + showResponse('success', t('admin.modal100.saved', '100% modal saved.')); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { completionModalSaveBtn.disabled = false; completionModalSaveBtn.textContent = oldText; @@ -1302,7 +1323,7 @@ window.onload = async function () { saveOscarDateBtn.addEventListener('click', async () => { const y = parseYear(oscarDateYearSelect.value); if (!y) { - showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026')); return; } @@ -1315,21 +1336,21 @@ window.onload = async function () { saveOscarDateBtn.disabled = true; if (clearOscarDateBtn) clearOscarDateBtn.disabled = true; const oldText = saveOscarDateBtn.textContent; - saveOscarDateBtn.textContent = 'Enregistrement...'; + saveOscarDateBtn.textContent = t('common.saving', 'Saving...'); try { const result = await setOscarDateForYear(y, dateValue); if (result?.date) { oscarDatesByYear[String(y)] = String(result.date); - showResponse('success', `Date Oscars enregistrée pour ${y}: ${result.date}`); + showResponse('success', t('admin.oscars.dateSaved', 'Oscars date saved for {year}: {date}', { year: y, date: result.date })); } else { delete oscarDatesByYear[String(y)]; - showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`); + showResponse('success', t('admin.oscars.dateCleared', 'Oscars date cleared for {year} (reverted to default: March 15).', { year: y })); } await refreshYears(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { saveOscarDateBtn.disabled = false; if (clearOscarDateBtn) clearOscarDateBtn.disabled = false; @@ -1343,23 +1364,23 @@ window.onload = async function () { clearOscarDateBtn.addEventListener('click', async () => { const y = parseYear(oscarDateYearSelect.value); if (!y) { - showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026')); return; } clearOscarDateBtn.disabled = true; if (saveOscarDateBtn) saveOscarDateBtn.disabled = true; const oldText = clearOscarDateBtn.textContent; - clearOscarDateBtn.textContent = 'Effacement...'; + clearOscarDateBtn.textContent = t('common.saving', 'Saving...'); try { oscarDateValueInput.value = ''; await setOscarDateForYear(y, ''); delete oscarDatesByYear[String(y)]; - showResponse('success', `Date Oscars effacée pour ${y} (retour au défaut: 15 mars).`); + showResponse('success', t('admin.oscars.dateCleared', 'Oscars date cleared for {year} (reverted to default: March 15).', { year: y })); await refreshYears(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { clearOscarDateBtn.disabled = false; if (saveOscarDateBtn) saveOscarDateBtn.disabled = false; @@ -1383,11 +1404,11 @@ window.onload = async function () { if (!text || text === '—') return; try { await navigator.clipboard.writeText(text); - setAlert(resetUserModalResponseEl, 'success', 'Copié.'); + setAlert(resetUserModalResponseEl, 'success', t('admin.users.copied', 'Copied.')); setTimeout(() => hideAlert(resetUserModalResponseEl), 1200); } catch (_) { // Fallback: select text for manual copy - setAlert(resetUserModalResponseEl, 'warning', 'Impossible de copier automatiquement. Copie manuellement.'); + setAlert(resetUserModalResponseEl, 'warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.')); } }); } @@ -1398,10 +1419,10 @@ window.onload = async function () { if (!text || text === '—') return; try { await navigator.clipboard.writeText(text); - setAlert(addUserResponseEl, 'success', 'Copié.'); + setAlert(addUserResponseEl, 'success', t('admin.users.copied', 'Copied.')); setTimeout(() => hideAlert(addUserResponseEl), 1200); } catch (_) { - setAlert(addUserResponseEl, 'warning', 'Impossible de copier automatiquement. Copie manuellement.'); + setAlert(addUserResponseEl, 'warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.')); } }); } @@ -1433,16 +1454,16 @@ window.onload = async function () { const admin = !!addUserAdminEl?.checked; if (!name) { - setAlert(addUserResponseEl, 'warning', 'Nom requis.'); + setAlert(addUserResponseEl, 'warning', t('admin.users.nameRequired', 'Name required.')); return; } if (!email) { - setAlert(addUserResponseEl, 'warning', 'Email requis.'); + setAlert(addUserResponseEl, 'warning', t('admin.users.emailRequired', 'Email required.')); return; } if (addUserSubmitBtn) addUserSubmitBtn.disabled = true; - setAlert(addUserResponseEl, 'warning', 'Création...'); + setAlert(addUserResponseEl, 'warning', t('admin.users.creating', 'Creating…')); try { const res = await fetch('/api/users/admin/create', { @@ -1463,15 +1484,15 @@ window.onload = async function () { // Show temp password once if (addUserTempPasswordEl) addUserTempPasswordEl.textContent = data?.tempPassword || '—'; - if (addUserExpiresAtEl) addUserExpiresAtEl.textContent = data?.expiresAt ? `Expire: ${data.expiresAt}` : ''; + if (addUserExpiresAtEl) addUserExpiresAtEl.textContent = data?.expiresAt ? t('admin.users.tempPasswordExpires', 'Expires: {expiresAt}', { expiresAt: data.expiresAt }) : ''; if (addUserResultEl) addUserResultEl.classList.remove('d-none'); - setAlert(addUserResponseEl, 'success', data.message || 'Utilisateur créé.'); + setAlert(addUserResponseEl, 'success', data.message || t('admin.users.userCreated', 'User created. Temporary password generated.')); if (addUserSubmitBtn) addUserSubmitBtn.disabled = false; // Refresh list if already loaded or when user tab is active await loadAdminUsers({ force: true }); } catch (err) { - setAlert(addUserResponseEl, 'danger', err.message || 'Erreur réseau'); + setAlert(addUserResponseEl, 'danger', err.message || 'Network error'); if (addUserSubmitBtn) addUserSubmitBtn.disabled = false; } }); @@ -1485,7 +1506,7 @@ window.onload = async function () { confirmResetUserPasswordBtn.disabled = true; resetResetUserModalUi(); - setAlert(resetUserModalResponseEl, 'warning', 'Génération du mot de passe temporaire...'); + setAlert(resetUserModalResponseEl, 'warning', t('admin.users.tempPasswordGenerating', 'Generating temporary password…')); try { const res = await fetch('/api/users/admin/reset-temp-password', { @@ -1509,11 +1530,11 @@ window.onload = async function () { resetUserExpiresAtEl.textContent = data?.expiresAt ? `Expire: ${data.expiresAt}` : ''; } if (resetUserModalResultEl) resetUserModalResultEl.classList.remove('d-none'); - setAlert(resetUserModalResponseEl, 'success', 'Mot de passe temporaire généré. Copie-le et envoie-le à l’utilisateur.'); + setAlert(resetUserModalResponseEl, 'success', t('admin.users.tempPasswordGenerated', 'Temporary password generated. Copy it and send it to the user.')); confirmResetUserPasswordBtn.disabled = false; await loadAdminUsers({ force: true }); } catch (err) { - setAlert(resetUserModalResponseEl, 'danger', err.message || 'Erreur réseau'); + setAlert(resetUserModalResponseEl, 'danger', err.message || 'Network error'); confirmResetUserPasswordBtn.disabled = false; } }); @@ -1548,14 +1569,14 @@ window.onload = async function () { return; } - setAlert(deleteUserModalResponseEl, 'success', data.message || 'Utilisateur supprimé.'); + setAlert(deleteUserModalResponseEl, 'success', data.message || t('admin.users.userDeleted', 'User deleted.')); await loadAdminUsers({ force: true }); // Close shortly after success setTimeout(() => { try { deleteUserModal?.hide(); } catch (_) {} }, 600); } catch (err) { - setAlert(deleteUserModalResponseEl, 'danger', err.message || 'Erreur réseau'); + setAlert(deleteUserModalResponseEl, 'danger', err.message || 'Network error'); } }); } @@ -1621,7 +1642,16 @@ window.onload = async function () { } dbBackupListBody.innerHTML = ''; - list.forEach((b) => { + const sorted = list.slice().sort((a, b) => { + const at = a?.mtime ? new Date(a.mtime).getTime() : 0; + const bt = b?.mtime ? new Date(b.mtime).getTime() : 0; + if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at; // newest first + const an = String(a?.name || ''); + const bn = String(b?.name || ''); + return an.localeCompare(bn, 'fr', { sensitivity: 'base' }); + }); + + sorted.forEach((b) => { const name = String(b?.name || ''); const tr = document.createElement('tr'); tr.innerHTML = ` @@ -1629,8 +1659,8 @@ window.onload = async function () { ${formatBytes(b?.sizeBytes)} ${formatDateTime(b?.mtime)} - - + + `; dbBackupListBody.appendChild(tr); @@ -1662,7 +1692,7 @@ window.onload = async function () { a.remove(); URL.revokeObjectURL(url); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -1674,7 +1704,7 @@ window.onload = async function () { btn.addEventListener('click', async () => { const name = btn.getAttribute('data-backup-delete'); if (!name) return; - const ok = window.confirm(`Supprimer la sauvegarde "${name}" ?`); + const ok = window.confirm(t('admin.backup.confirmDelete', `Delete backup "${name}"?`, { name })); if (!ok) return; btn.disabled = true; const old = btn.textContent; @@ -1688,10 +1718,10 @@ window.onload = async function () { if (!res.ok) { throw new Error(data.message || data.error || `Erreur (${res.status})`); } - showResponse('success', data.message || 'Sauvegarde supprimée.'); + showResponse('success', data.message || t('admin.backup.deleted', 'Backup deleted.')); await loadBackups({ force: true }); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -1710,7 +1740,7 @@ window.onload = async function () { backupsLoadedOnce = true; renderBackups(backups); } catch (err) { - dbBackupListBody.innerHTML = `${err.message || 'Erreur réseau'}`; + dbBackupListBody.innerHTML = `${err.message || 'Network error'}`; } } @@ -1718,7 +1748,7 @@ window.onload = async function () { if (!dbBackupCreateBtn) return; dbBackupCreateBtn.disabled = true; const old = dbBackupCreateBtn.textContent; - dbBackupCreateBtn.textContent = 'Création...'; + dbBackupCreateBtn.textContent = t('admin.backup.creating', 'Creating…'); try { const res = await fetch('/api/admin/db/backup', { method: 'POST', @@ -1728,10 +1758,10 @@ window.onload = async function () { if (!res.ok) { throw new Error(data.message || data.error || `Erreur (${res.status})`); } - showResponse('success', `Sauvegarde créée: ${data?.backup?.name || 'OK'}`); + showResponse('success', t('admin.backup.created', 'Backup created: {name}', { name: data?.backup?.name || 'OK' })); await loadBackups({ force: true }); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { dbBackupCreateBtn.disabled = false; dbBackupCreateBtn.textContent = old; @@ -1742,21 +1772,21 @@ window.onload = async function () { if (!dbRestoreRunBtn || !dbRestoreFileEl) return; const file = dbRestoreFileEl.files && dbRestoreFileEl.files[0]; if (!file) { - showResponse('warning', 'Choisis un fichier de sauvegarde (.ndjson.gz).'); + showResponse('warning', t('admin.backup.chooseFile', 'Choose a backup file (.ndjson.gz).')); return; } const drop = !!dbRestoreDropEl?.checked; const warning = drop - ? 'RESTORE avec DROP: toutes les collections restaurées seront remplacées.' - : 'RESTORE sans DROP: risque élevé de doublons/erreurs si la DB contient déjà des données.'; + ? t('admin.backup.restoreWithDrop', 'RESTORE with DROP: all restored collections will be replaced.') + : t('admin.backup.restoreWithoutDrop', 'RESTORE without DROP: high risk of duplicates/errors if the DB already contains data.'); - const ok = window.confirm(`${warning}\n\nContinuer ?`); + const ok = window.confirm(`${warning}\n\n${t('common.continueQuestion', 'Continue?')}`); if (!ok) return; dbRestoreRunBtn.disabled = true; const old = dbRestoreRunBtn.textContent; - dbRestoreRunBtn.textContent = 'Restauration...'; + dbRestoreRunBtn.textContent = t('common.saving', 'Saving…'); if (dbRestoreResultEl) dbRestoreResultEl.textContent = ''; try { @@ -1774,17 +1804,17 @@ window.onload = async function () { throw new Error(data.message || data.error || `Erreur (${res.status})`); } - showResponse('success', 'Restore terminé.'); + showResponse('success', t('admin.backup.restoreComplete', 'Restore complete.')); if (dbRestoreResultEl) { const inserted = data?.inserted && typeof data.inserted === 'object' ? data.inserted : {}; const keys = Object.keys(inserted); const summary = keys.length ? keys.map((k) => `${k}: ${inserted[k]}`).join(' | ') - : 'Aucun document inséré (ou backup vide).'; + : t('admin.backup.noDocumentsInserted', 'No documents inserted (or empty backup).'); dbRestoreResultEl.textContent = summary; } } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { dbRestoreRunBtn.disabled = false; dbRestoreRunBtn.textContent = old; @@ -1821,9 +1851,9 @@ window.onload = async function () { if (appVersionNewMessageEl) appVersionNewMessageEl.value = ''; renderAppVersionUI(); await refreshAppVersionPreview(); - setAppVersionAlert('success', activate ? 'Version créée et activée.' : 'Version créée.'); + setAppVersionAlert('success', activate ? t('admin.version.createdAndActivated', 'Version created & activated.') : t('admin.version.created', 'Version created.')); } catch (err) { - setAppVersionAlert('danger', err.message || 'Erreur réseau'); + setAppVersionAlert('danger', err.message || 'Network error'); } finally { btn.disabled = false; btn.textContent = old; @@ -1867,7 +1897,7 @@ window.onload = async function () { ]); renderWinnerCurrent(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } } @@ -1886,7 +1916,7 @@ window.onload = async function () { winnerReloadBtn.textContent = 'Chargement...'; try { await initWinnersTab({ force: true }); - showResponse('success', 'Gagnants rechargés.'); + showResponse('success', t('admin.winners.reloaded', 'Winners reloaded.')); } finally { winnerReloadBtn.disabled = false; winnerReloadBtn.textContent = old; @@ -1899,16 +1929,16 @@ window.onload = async function () { if (!winnerYearSelect) return; const y = parseYear(winnerYearSelect.value); if (!y) return; - const ok = window.confirm(`Supprimer tous les gagnants pour ${y} ?`); + const ok = window.confirm(t('admin.winners.confirmDeleteAll', 'Delete all winners for {year}?', { year: y })); if (!ok) return; try { if (winnerUserSelect) winnerUserSelect.value = ''; if (winnerPointsEl) winnerPointsEl.value = ''; await addWinner(y, '', null); await loadWinners({ force: true }); - showResponse('success', `Gagnant supprimé pour ${y}.`); + showResponse('success', t('admin.winners.deletedFor', 'Winner deleted for {year}.', { year: y })); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } }); } @@ -1918,12 +1948,12 @@ window.onload = async function () { if (!winnerYearSelect) return; const y = parseYear(winnerYearSelect.value); if (!y) { - showResponse('warning', 'Année invalide.'); + showResponse('warning', t('admin.oscars.invalidYearShort', 'Invalid year.')); return; } const userId = String(winnerUserSelect?.value || '').trim(); if (!userId) { - showResponse('warning', 'Choisis un utilisateur.'); + showResponse('warning', t('admin.winners.chooseUser', 'Choose a user.')); return; } const pointsRaw = String(winnerPointsEl?.value || '').trim(); @@ -1932,13 +1962,13 @@ window.onload = async function () { winnerSaveBtn.disabled = true; const old = winnerSaveBtn.textContent; - winnerSaveBtn.textContent = 'Enregistrement...'; + winnerSaveBtn.textContent = t('common.saving', 'Saving…'); try { await addWinner(y, userId, pointsToSend); await loadWinners({ force: true }); - showResponse('success', `Gagnant ajouté pour ${y}.`); + showResponse('success', t('admin.winners.addedFor', 'Winner added for {year}.', { year: y })); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { winnerSaveBtn.disabled = false; winnerSaveBtn.textContent = old; @@ -1965,7 +1995,7 @@ window.onload = async function () { const subtitle_default = false; if (!year) { - showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + showResponse('warning', t('admin.oscars.invalidYearExample', 'Invalid year. Expected example: 2026')); return; } @@ -2010,15 +2040,14 @@ window.onload = async function () { label: subtitle_label, isDefault: subtitle_default, }); - showResponse('success', 'Film ajouté avec sous-titres.'); + showResponse('success', t('admin.movies.addedWithSubtitles', 'Movie added with subtitles.')); } catch (err) { - showResponse('warning', `Film ajouté, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); + showResponse('warning', t('admin.movies.addedNoSubtitles', 'Movie added, but subtitles were not uploaded: {error}', { error: err.message || 'Error' })); } } else { - showResponse('success', 'Film ajouté avec succès.'); + showResponse('success', t('admin.movies.addedSuccessfully', 'Movie added successfully.')); } form.reset(); - if (subtitleFileInput) subtitleFileInput.value = ''; await refreshYears(); await loadMoviesForManagement(); return; @@ -2033,7 +2062,7 @@ window.onload = async function () { } showResponse('danger', errMessage); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } }); @@ -2054,7 +2083,7 @@ window.onload = async function () { manageYearSelect.innerHTML = ''; const allOpt = document.createElement('option'); allOpt.value = ''; - allOpt.textContent = 'Toutes'; + allOpt.textContent = t('admin.movies.allYears', 'All'); manageYearSelect.appendChild(allOpt); cleanedYears.forEach((y) => { @@ -2090,7 +2119,7 @@ window.onload = async function () { if (cleanedYears.length === 0) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'Aucune année (ajoute un film)'; + opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)'); opt.disabled = true; opt.selected = true; activeYearInput.appendChild(opt); @@ -2126,7 +2155,7 @@ window.onload = async function () { if (cleanedYears.length === 0) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'Aucune année (ajoute un film)'; + opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)'); opt.disabled = true; opt.selected = true; oscarDateYearSelect.appendChild(opt); @@ -2176,7 +2205,7 @@ window.onload = async function () { if (cleanedYears.length === 0) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'Aucune année (ajoute un film)'; + opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)'); opt.disabled = true; manualYearSelect.appendChild(opt); } else { @@ -2204,7 +2233,7 @@ window.onload = async function () { // Add "All years" option const allOpt = document.createElement('option'); allOpt.value = ''; - allOpt.textContent = 'Toutes les années'; + allOpt.textContent = t('admin.categories.allYears', 'All years'); listYearFilterSelect.appendChild(allOpt); if (cleanedYears.length > 0) { @@ -2263,7 +2292,7 @@ window.onload = async function () { const file = escapeHtml(String(s?.file || '')); const title = label || lang || file || 'Sous-titres'; const meta = [lang, label].filter(Boolean).join(' · '); - const isDefault = s?.default ? 'Par défaut' : ''; + const isDefault = s?.default ? `${t('admin.settings.defaultBadge', 'Default')}` : ''; return `
@@ -2273,7 +2302,7 @@ window.onload = async function () { ${isDefault}
`; @@ -2284,16 +2313,16 @@ window.onload = async function () { const subtitleId = btn.getAttribute('data-remove-subtitle-id'); const movieId = String(editMovieIdEl?.value || ''); if (!movieId || !subtitleId) return; - if (!confirm('Supprimer ce sous-titre ?')) return; + if (!confirm(t('admin.movies.confirmRemoveSubtitle','Delete this subtitle?'))) return; try { btn.disabled = true; const updated = await removeMovieSubtitle({ movieId, subtitleId }); moviesById.set(updated._id, updated); renderSubtitleList(updated); - showResponse('success', 'Sous-titre supprimé.'); + showResponse('success', t('admin.movies.subtitleDeleted', 'Subtitle deleted.')); await loadMoviesForManagement(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { btn.disabled = false; } @@ -2376,13 +2405,13 @@ window.onload = async function () { if (yearRaw !== '') { year = parseYear(yearRaw); if (!year) { - showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + showResponse('warning', t('admin.oscars.invalidYearExample', 'Invalid year. Expected example: 2026')); return; } } if (!category) { - showResponse('warning', 'Catégorie invalide.'); + showResponse('warning', t('admin.categories.categoryInvalid', 'Invalid category.')); return; } @@ -2418,7 +2447,7 @@ window.onload = async function () { try { saveMovieChangesBtn.disabled = true; - saveMovieChangesBtn.textContent = 'Enregistrement...'; + saveMovieChangesBtn.textContent = t('common.saving', 'Saving...'); const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, { method: 'PUT', @@ -2451,16 +2480,16 @@ window.onload = async function () { }); if (uploaded) moviesById.set(uploaded._id, uploaded); } catch (err) { - showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); + showResponse('warning', t('admin.movies.updatedNoSubtitles', 'Movie updated, but subtitles were not uploaded: {error}', { error: err.message || 'Error' })); } } moviesById.set(updated._id, updated); - showResponse('success', 'Film mis à jour avec succès.'); + showResponse('success', t('admin.movies.updatedSuccessfully', 'Movie updated successfully.')); editMovieModal.hide(); await refreshYears(); await loadMoviesForManagement(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { saveMovieChangesBtn.disabled = false; saveMovieChangesBtn.textContent = 'Enregistrer'; @@ -2483,7 +2512,7 @@ window.onload = async function () { moviesById = new Map(movies.map((m) => [m._id, m])); if (!movies.length) { - adminMoviesBody.innerHTML = `Aucun film.`; + adminMoviesBody.innerHTML = `${t('admin.movies.noMovies', 'No movies.')}`; updateSelectionUI(); return; } @@ -2544,7 +2573,7 @@ window.onload = async function () { ${m.year || ''} `; @@ -2565,16 +2594,16 @@ window.onload = async function () { if (!id) return; const ok = await copyToClipboard(id); if (ok) { - showResponse('success', 'Movie ID copié.'); + showResponse('success', t('admin.movies.idCopied', 'Movie ID copied.')); } else { - showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.'); + showResponse('warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.')); } }); }); updateSelectionUI(); } catch (err) { - adminMoviesBody.innerHTML = `${err.message || 'Erreur réseau'}`; + adminMoviesBody.innerHTML = `${err.message || 'Network error'}`; } } @@ -2594,7 +2623,7 @@ window.onload = async function () { if (!selectedIds.length) return; const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : ''; - const ok = window.confirm(`Supprimer ${selectedIds.length} film(s)${yearLabel} ? Cette action est irréversible.`); + const ok = window.confirm(t('admin.movies.confirmDeleteSelected', `Delete ${selectedIds.length} movie(s)${yearLabel}? This action is irreversible.`, { count: selectedIds.length })); if (!ok) return; try { @@ -2617,11 +2646,11 @@ window.onload = async function () { return; } - showResponse('success', 'Film(s) supprimé(s) avec succès.'); + showResponse('success', t('admin.movies.deletedSuccessfully', 'Movie(s) deleted successfully.')); await refreshYears(); await loadMoviesForManagement(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } }); @@ -2631,7 +2660,7 @@ window.onload = async function () { if (!selectedIds.length) return; const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : ''; - const ok = window.confirm(`Rafraîchir ${selectedIds.length} film(s)${yearLabel} depuis OMDb ? Cette opération peut écraser les infos (titre, plot, rating, poster, runtime).`); + const ok = window.confirm(t('admin.movies.confirmRefreshOmdb', `Refresh ${selectedIds.length} movie(s)${yearLabel} from OMDb? This operation may overwrite info (title, plot, rating, poster, runtime).`, { count: selectedIds.length })); if (!ok) return; updateOmdbSelectedBtn.disabled = true; @@ -2664,7 +2693,7 @@ window.onload = async function () { successCount++; } } catch (err) { - errors.push(`${movieId}: ${err.message || 'Erreur réseau'}`); + errors.push(`${movieId}: ${err.message || 'Network error'}`); errorCount++; } } @@ -2673,11 +2702,16 @@ window.onload = async function () { updateOmdbSelectedBtn.textContent = 'Refresh OMDb'; if (errorCount === 0) { - showResponse('success', `${successCount} film(s) mis à jour depuis OMDb.`); + showResponse('success', t('admin.movies.updatedFromOmdb', '{count} movie(s) updated from OMDb.', { count: successCount })); } else if (successCount === 0) { - showResponse('danger', `Échec pour ${errorCount} film(s). ${errors.slice(0, 3).join('; ')}`); + showResponse('danger', `Failed for ${errorCount} movie(s). ${errors.slice(0, 3).join('; ')}`); } else { - showResponse('warning', `${successCount} succès, ${errorCount} échec(s). ${errors.slice(0, 2).join('; ')}`); + const details = errors.slice(0, 2).join('; '); + showResponse('warning', t('admin.movies.omdbResults', '{success} success, {errors} error(s). {details}', { + success: successCount, + errors: errorCount, + details, + })); } await loadMoviesForManagement(); @@ -2694,9 +2728,9 @@ window.onload = async function () { if (!id) return; const ok = await copyToClipboard(id); if (ok) { - showResponse('success', 'Movie ID copié.'); + showResponse('success', t('admin.movies.idCopied', 'Movie ID copied.')); } else { - showResponse('warning', 'Impossible de copier automatiquement. Copie manuellement.'); + showResponse('warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.')); } }); } @@ -2718,7 +2752,7 @@ window.onload = async function () { playerUiReloadBtn.textContent = 'Chargement...'; try { await loadPlayerUiIntoUi(); - showResponse('success', 'Réglages du lecteur rechargés.'); + showResponse('success', t('admin.player.settingsReloaded', 'Player settings reloaded.')); } finally { playerUiReloadBtn.disabled = false; playerUiReloadBtn.textContent = old; @@ -2730,14 +2764,14 @@ window.onload = async function () { playerUiSaveBtn.addEventListener('click', async () => { playerUiSaveBtn.disabled = true; const old = playerUiSaveBtn.textContent; - playerUiSaveBtn.textContent = 'Enregistrement...'; + playerUiSaveBtn.textContent = 'Saving...'; try { const payload = getPlayerUiFormValue(); const saved = await savePlayerUi(payload); setPlayerUiForm(saved); - showResponse('success', 'Réglages du lecteur enregistrés.'); + showResponse('success', t('admin.player.settingsSaved', 'Player settings saved.')); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { playerUiSaveBtn.disabled = false; playerUiSaveBtn.textContent = old; @@ -2749,7 +2783,7 @@ window.onload = async function () { saveActiveYearBtn.addEventListener('click', async () => { const y = parseYear(activeYearInput.value); if (!y) { - showResponse('warning', 'Année invalide. Exemple attendu: 2026'); + showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026')); return; } saveActiveYearBtn.disabled = true; @@ -2760,11 +2794,11 @@ window.onload = async function () { activeYearInput.value = String(activeYear); yearInput.value = String(activeYear); document.title = `Pool Oscars (${activeYear}) - Admin`; - showResponse('success', `Année active appliquée: ${activeYear}`); + showResponse('success', t('admin.activeYear.applied', 'Active year applied: {year}', { year: activeYear })); await refreshYears(); await loadMoviesForManagement(); } catch (err) { - showResponse('danger', err.message || 'Erreur réseau'); + showResponse('danger', err.message || 'Network error'); } finally { saveActiveYearBtn.disabled = false; saveActiveYearBtn.textContent = oldText; @@ -2816,7 +2850,7 @@ window.onload = async function () { if (pointsConfigReloadBtn) { pointsConfigReloadBtn.addEventListener('click', async () => { await loadPointsConfig(); - showResponse('success', 'Configuration rechargée'); + showResponse('success', t('admin.configuration.reloaded', 'Configuration reloaded.')); }); } @@ -2828,7 +2862,7 @@ window.onload = async function () { if (pointsPerMovie < 0 || pointsPerCorrectPick < 0) { if (pointsConfigAlert) { pointsConfigAlert.className = 'alert alert-warning'; - pointsConfigAlert.textContent = 'Les points doivent être positifs ou nuls'; + pointsConfigAlert.textContent = t('admin.settings.pointsMustBeNonNegative', 'Points must be zero or positive'); pointsConfigAlert.classList.remove('d-none'); } return; @@ -2837,7 +2871,7 @@ window.onload = async function () { try { pointsConfigSaveBtn.disabled = true; const oldText = pointsConfigSaveBtn.textContent; - pointsConfigSaveBtn.textContent = 'Enregistrement...'; + pointsConfigSaveBtn.textContent = t('common.saving', 'Saving...'); const res = await fetch('/api/settings/points-config', { method: 'PUT', @@ -2858,7 +2892,7 @@ window.onload = async function () { const data = await res.json(); updatePointsPreview(); - showResponse('success', `Configuration enregistrée: ${data.pointsPerMovie} pts/film, ${data.pointsPerCorrectPick} pts/bonne réponse`); + showResponse('success', t('admin.settings.pointsSaved', 'Configuration saved: {p} pts/movie, {q} pts/correct pick', { p: data.pointsPerMovie, q: data.pointsPerCorrectPick })); } catch (err) { if (pointsConfigAlert) { pointsConfigAlert.className = 'alert alert-danger'; @@ -2876,10 +2910,25 @@ window.onload = async function () { // Visibility config const visibilityShowPicksFeaturesEl = document.getElementById('visibility-show-picks-features'); + const visibilityShowVersionTabEl = document.getElementById('visibility-show-version-tab'); const visibilityConfigReloadBtn = document.getElementById('visibility-config-reload'); const visibilityConfigSaveBtn = document.getElementById('visibility-config-save'); const visibilityConfigAlert = document.getElementById('visibility-config-alert'); + function applyVersionTabVisibility(showVersionTab) { + const versionTabBtn = document.getElementById('admin-version-tab'); + const versionPane = document.getElementById('admin-version'); + if (versionTabBtn) versionTabBtn.style.display = showVersionTab ? '' : 'none'; + if (versionPane) versionPane.style.display = showVersionTab ? '' : 'none'; + if (!showVersionTab) { + const activeTab = document.querySelector('#adminTabs .nav-link.active'); + if (activeTab && activeTab.id === 'admin-version-tab') { + const fallback = document.getElementById('admin-settings-tab'); + if (fallback) fallback.click(); + } + } + } + async function loadVisibilityConfig() { try { const res = await fetch('/api/settings/visibility-config', { cache: 'no-store' }); @@ -2889,13 +2938,19 @@ window.onload = async function () { // Both settings should be the same - use showPicksButton as the master const isEnabled = config.showPicksButton !== false && config.showBonPicksColumn !== false; if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = isEnabled; + if (visibilityShowVersionTabEl) visibilityShowVersionTabEl.checked = config.showVersionTab !== false; + applyVersionTabVisibility(config.showVersionTab !== false); } catch (err) { console.error('Error loading visibility config:', err); // Default to showing both - if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = true; + if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = false; + if (visibilityShowVersionTabEl) visibilityShowVersionTabEl.checked = true; + applyVersionTabVisibility(true); } } + await loadVisibilityConfig(); + if (visibilityConfigReloadBtn) { visibilityConfigReloadBtn.addEventListener('click', async () => { visibilityConfigReloadBtn.disabled = true; @@ -2906,7 +2961,7 @@ window.onload = async function () { loadVisibilityConfig(), loadPlayerUiIntoUi() ]); - showResponse('success', 'Configuration rechargée'); + showResponse('success', t('admin.configuration.reloaded', 'Configuration reloaded.')); } catch (err) { showResponse('warning', 'Erreur lors du rechargement: ' + (err.message || 'Erreur inconnue')); } finally { @@ -2921,11 +2976,12 @@ window.onload = async function () { const isEnabled = visibilityShowPicksFeaturesEl ? visibilityShowPicksFeaturesEl.checked : true; const showPicksButton = isEnabled; const showBonPicksColumn = isEnabled; + const showVersionTab = visibilityShowVersionTabEl ? visibilityShowVersionTabEl.checked : true; try { visibilityConfigSaveBtn.disabled = true; const oldText = visibilityConfigSaveBtn.textContent; - visibilityConfigSaveBtn.textContent = 'Enregistrement...'; + visibilityConfigSaveBtn.textContent = t('common.saving', 'Saving...'); // Save both visibility config and player UI settings const [visibilityRes, playerUiPayload] = await Promise.all([ @@ -2937,7 +2993,8 @@ window.onload = async function () { }, body: JSON.stringify({ showPicksButton, - showBonPicksColumn + showBonPicksColumn, + showVersionTab }) }), Promise.resolve(getPlayerUiFormValue()) @@ -2961,18 +3018,20 @@ window.onload = async function () { // Clear cache so all pages get the new setting immediately localStorage.removeItem('visibility_config_cache'); + + applyVersionTabVisibility(showVersionTab); - showResponse('success', 'Configuration enregistrée'); + showResponse('success', t('admin.visibility.saved', 'Configuration saved')); } catch (err) { if (visibilityConfigAlert) { visibilityConfigAlert.className = 'alert alert-danger'; - visibilityConfigAlert.textContent = 'Erreur: ' + err.message; + visibilityConfigAlert.textContent = t('admin.visibility.saveError', 'Error saving configuration: {error}', { error: err.message }); visibilityConfigAlert.classList.remove('d-none'); } - showResponse('danger', 'Erreur lors de l\'enregistrement: ' + err.message); + showResponse('danger', t('admin.visibility.saveError', 'Error saving configuration: {error}', { error: err.message })); } finally { visibilityConfigSaveBtn.disabled = false; - visibilityConfigSaveBtn.textContent = oldText; + visibilityConfigSaveBtn.textContent = oldText || t('common.save', 'Save'); } }); } @@ -2988,6 +3047,82 @@ window.onload = async function () { }); } + + // Language config + const siteLanguageSelect = document.getElementById('site_language'); + const languageConfigSaveBtn = document.getElementById('language-config-save'); + const languageConfigAlert = document.getElementById('language-config-alert'); + + async function loadLanguageConfig() { + try { + const res = await fetch('/api/settings/language', { cache: 'no-store' }); + if (!res.ok) throw new Error('Failed to load language setting'); + const data = await res.json(); + if (siteLanguageSelect) { + siteLanguageSelect.value = data.language || 'en'; + } + } catch (err) { + console.error('Error loading language config:', err); + if (siteLanguageSelect) siteLanguageSelect.value = 'en'; + } + } + + if (languageConfigSaveBtn) { + languageConfigSaveBtn.addEventListener('click', async () => { + const language = siteLanguageSelect ? siteLanguageSelect.value : 'en'; + + try { + languageConfigSaveBtn.disabled = true; + const oldText = languageConfigSaveBtn.textContent; + languageConfigSaveBtn.textContent = 'Saving...'; + + const res = await fetch('/api/settings/language', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ language }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || 'Failed to save language setting'); + } + + // Clear i18n cache and reload translations + if (typeof window.i18n !== 'undefined' && window.i18n.setLanguage) { + await window.i18n.setLanguage(language); + // After applying the new language, reload the page so all UI text updates + // (this avoids requiring the admin to manually refresh the page). + setTimeout(() => { + try { window.location.reload(); } catch (_) {} + }, 200); + } + + showResponse('success', t('admin.language.saved', 'Language saved')); + } catch (err) { + if (languageConfigAlert) { + languageConfigAlert.className = 'alert alert-danger'; + languageConfigAlert.textContent = 'Error: ' + err.message; + languageConfigAlert.classList.remove('d-none'); + } + showResponse('danger', 'Error: ' + err.message); + } finally { + languageConfigSaveBtn.disabled = false; + languageConfigSaveBtn.textContent = typeof window.i18n !== 'undefined' ? window.i18n.t('common.save') : 'Save'; + } + }); + } + + // Load language config when language tab is shown + const languageSubTab = document.getElementById('admin-settings-subtab-language'); + if (languageSubTab) { + languageSubTab.addEventListener('shown.bs.tab', async () => { + await loadLanguageConfig(); + }); + } + // Categories management const createCategoryForm = document.getElementById('create-category-form'); const refreshCategoriesListBtn = document.getElementById('refresh-categories-list'); @@ -3014,7 +3149,7 @@ window.onload = async function () { const nomineesText = document.getElementById('nominees-list').value.trim(); if (!year || !categoryNumber || !categoryName || !nomineesText) { - showCategoriesAlert('Veuillez remplir tous les champs', 'warning'); + showCategoriesAlert(t('admin.categories.fillAllFields', 'Please fill in all fields'), 'warning'); return; } @@ -3023,7 +3158,7 @@ window.onload = async function () { .filter(line => line.length > 0); if (nominees.length === 0) { - showCategoriesAlert('Veuillez entrer au moins un nommé', 'warning'); + showCategoriesAlert(t('admin.categories.enterAtLeastOneNominee', 'Please enter at least one nominee'), 'warning'); return; } @@ -3048,7 +3183,7 @@ window.onload = async function () { } const data = await res.json(); - showCategoriesAlert('Catégorie créée avec succès', 'success'); + showCategoriesAlert(t('admin.categories.categoryCreated', 'Category created successfully.'), 'success'); createCategoryForm.reset(); if (refreshCategoriesListBtn) refreshCategoriesListBtn.click(); } catch (err) { @@ -3077,7 +3212,10 @@ window.onload = async function () { const categories = data.categories || []; if (categories.length === 0) { - categoriesListContainer.innerHTML = `
Aucune catégorie trouvée${year ? ` pour l'année ${year}` : ''}.
`; + const emptyMsg = year + ? t('admin.categories.noneFoundYear', 'No categories found for year {year}.', { year }) + : t('admin.categories.noneFound', 'No categories found.'); + categoriesListContainer.innerHTML = `
${emptyMsg}
`; return; } @@ -3087,12 +3225,12 @@ window.onload = async function () { - + - # - Nom - Nommés - Actions + ${t('admin.categories.table.number','#')} + ${t('admin.categories.table.name','Nom')} + ${t('admin.categories.table.nominees','Nominees')} + ${t('admin.categories.table.actions','Actions')} @@ -3105,12 +3243,12 @@ window.onload = async function () { ${cat.categoryName} - - + + `).join('')} @@ -3135,7 +3273,7 @@ window.onload = async function () { if (!res.ok) { if (res.status === 404) { - showResponse('danger', 'Catégorie non trouvée'); + showResponse('danger', t('admin.categories.categoryNotFound', 'Category not found.')); return; } throw new Error('Failed to load category'); @@ -3145,7 +3283,7 @@ window.onload = async function () { const category = data.category; if (!category) { - showResponse('danger', 'Catégorie non trouvée'); + showResponse('danger', t('admin.categories.categoryNotFound', 'Category not found.')); return; } @@ -3170,7 +3308,7 @@ window.onload = async function () { }; window.deleteCategory = async function(categoryId) { - if (!confirm('Êtes-vous sûr de vouloir supprimer cette catégorie?')) return; + if (!confirm(t('admin.categories.confirmDelete','Are you sure you want to delete this category?'))) return; try { const res = await fetch(`/api/categories/${categoryId}`, { @@ -3183,7 +3321,7 @@ window.onload = async function () { throw new Error(error.message || 'Delete failed'); } - showResponse('success', 'Catégorie supprimée'); + showResponse('success', t('admin.categories.categoryDeleted', 'Category deleted.')); await loadCategoriesList(); } catch (err) { showResponse('danger', 'Erreur: ' + err.message); @@ -3202,7 +3340,7 @@ window.onload = async function () { if (!categoryName || !nomineesText) { if (alertEl) { alertEl.className = 'alert alert-warning'; - alertEl.textContent = 'Veuillez remplir tous les champs'; + alertEl.textContent = t('admin.categories.fillAllFields', 'Please fill in all fields'); alertEl.classList.remove('d-none'); } return; @@ -3215,7 +3353,7 @@ window.onload = async function () { if (nominees.length === 0) { if (alertEl) { alertEl.className = 'alert alert-warning'; - alertEl.textContent = 'Veuillez entrer au moins un nommé'; + alertEl.textContent = t('admin.categories.enterAtLeastOneNominee', 'Please enter at least one nominee'); alertEl.classList.remove('d-none'); } return; @@ -3225,7 +3363,7 @@ window.onload = async function () { if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) { if (alertEl) { alertEl.className = 'alert alert-warning'; - alertEl.textContent = 'Le numéro de catégorie doit être entre 1 et 50'; + alertEl.textContent = t('admin.categories.numberBetween1And50', 'Category number must be between 1 and 50'); alertEl.classList.remove('d-none'); } return; @@ -3236,7 +3374,7 @@ window.onload = async function () { try { saveCategoryChangesBtn.disabled = true; - saveCategoryChangesBtn.textContent = 'Enregistrement...'; + saveCategoryChangesBtn.textContent = t('common.saving', 'Saving...'); // Hide any previous alerts if (alertEl) { @@ -3262,7 +3400,7 @@ window.onload = async function () { } const data = await res.json(); - showResponse('success', 'Catégorie mise à jour avec succès'); + showResponse('success', t('admin.categories.categoryUpdated', 'Category updated successfully.')); // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('editCategoryModal')); @@ -3358,8 +3496,12 @@ window.onload = async function () { const categoryIds = selected.map(cb => cb.dataset.categoryId); const categoryNames = selected.map(cb => cb.dataset.categoryName); - const confirmMessage = `Êtes-vous sûr de vouloir supprimer ${categoryIds.length} catégorie(s)?\n\n${categoryNames.join('\n')}\n\nCette action est irréversible.`; - if (!confirm(confirmMessage)) return; + const confirmMessage = t( + 'admin.categories.bulkDeleteConfirm', + `Are you sure you want to delete ${categoryIds.length} category(ies)?\n\n${categoryNames.join('\n')}\n\nThis action is irreversible.`, + { count: categoryIds.length } + ); + if (!confirm(t('admin.categories.confirmDeleteMultiple', confirmMessage, { count: categoryIds.length, names: categoryNames.join('\n') }))) return; try { bulkDeleteBtn.disabled = true; @@ -3389,10 +3531,14 @@ window.onload = async function () { } if (successCount > 0) { - showResponse('success', `${successCount} catégorie(s) supprimée(s)${errorCount > 0 ? `, ${errorCount} erreur(s)` : ''}`); + const errorsSuffix = errorCount > 0 ? `, ${errorCount} error(s)` : ''; + showResponse('success', t('admin.categories.deletedCount', '{count} category/categories deleted{errors}', { + count: successCount, + errors: errorsSuffix, + })); await loadCategoriesList(); } else { - showResponse('danger', `Erreur lors de la suppression: ${errorCount} erreur(s)`); + showResponse('danger', `Error while deleting: ${errorCount} error(s)`); } } catch (err) { showResponse('danger', 'Erreur: ' + err.message); diff --git a/public/js/app_header_boot.js b/public/js/app_header_boot.js index 0cfedb6..0a8088d 100644 --- a/public/js/app_header_boot.js +++ b/public/js/app_header_boot.js @@ -86,10 +86,12 @@ // Show button immediately based on cached value if (cachedValue.showPicksButton !== false) { picksButton.style.display = ''; + } else { + picksButton.style.display = 'none'; } } else { - // Default to showing while we fetch - picksButton.style.display = ''; + // Default to hiding while we fetch (new installs should start disabled) + picksButton.style.display = 'none'; } // Fetch fresh value from server diff --git a/public/js/app_index.js b/public/js/app_index.js index f42b0a4..88cae82 100644 --- a/public/js/app_index.js +++ b/public/js/app_index.js @@ -2,6 +2,39 @@ function decodeJwt(token) { return JSON.parse(atob(token.split('.')[1])); } +// i18n helper function +function t(key, fallback = '', params = {}) { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params); + } + let result = fallback; + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + result = result.replace(`{${k}}`, String(params[k])); + }); + } + return result; +} + +function translateApiMessage(messageKey, message, fallbackKey, fallbackText) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + if (messageKey) return window.i18n.t(messageKey); + const map = { + "L'utilisateur n'a pas été trouvé": 'auth.userNotFound', + 'User not found': 'auth.userNotFound', + "Informations d'identification non valides": 'auth.invalidCredentials', + 'Invalid credentials': 'auth.invalidCredentials', + "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.": 'auth.tempPasswordExpired', + 'Temporary password expired. Ask an admin to reset your password.': 'auth.tempPasswordExpired', + }; + if (message && map[message]) return window.i18n.t(map[message]); + if (fallbackKey) return window.i18n.t(fallbackKey); + } + } catch (_) {} + return message || fallbackText || ''; +} + async function applyActiveOscarYearToTitle() { const base = 'Pool Oscars'; const current = String(document.title || base); @@ -110,13 +143,13 @@ if (setupForm) { window.location.href = '/movies.html'; } else { if (setupError) { - setupError.textContent = data.message || 'Erreur lors de la création du compte'; + setupError.textContent = data.message || t('auth.errorCreatingAccount', 'Error creating account'); setupError.classList.remove('d-none'); } } } catch (err) { if (setupError) { - setupError.textContent = 'Erreur réseau. Veuillez réessayer.'; + setupError.textContent = t('auth.networkError', 'Network error. Please try again.'); setupError.classList.remove('d-none'); } } @@ -147,7 +180,8 @@ document.getElementById('login-form').addEventListener('submit', async function window.location.href = '/movies.html'; // Redirect to movies page on success } } else { - const error = await res.json(); - alert(`${error.message}`); + const error = await res.json().catch(() => ({})); + const msg = translateApiMessage(error.messageKey, error.message, 'auth.loginFailed', 'Login failed'); + alert(msg); } }); \ No newline at end of file diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 11d81c2..6507f8d 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -1,7 +1,21 @@ +// i18n helper function +function t(key, fallback = '', params = {}) { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params); + } + let result = fallback; + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + result = result.replace(`{${k}}`, String(params[k])); + }); + } + return result; +} + function createPageLoader(options = {}) { - const title = String(options.title || 'Chargement…'); + const title = String(options.title || t('common.loading', 'Loading…')); // Subtitle kept for backwards-compat with callers, but we no longer render text in the UI. - const subtitle = String(options.subtitle || 'Préparation de la page…'); + const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…')); let progress = 0; let removed = false; @@ -147,8 +161,8 @@ async function waitForImagesIn(containerEl, onProgress) { window.addEventListener('DOMContentLoaded', async function () { const pageLoader = createPageLoader({ - title: 'Chargement des films', - subtitle: 'Récupération des données…' + title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.loadingMovies') : 'Loading movies', + subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…' }); function formatClock(seconds) { @@ -272,7 +286,11 @@ window.addEventListener('DOMContentLoaded', async function () { if (avgEl) { if (avg && count > 0) { - avgEl.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`; + const avgLabel = `${avg.toFixed(1)}${count ? ` (${count})` : ''}`; + const translated = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.userRating', { rating: avgLabel }) + : `User rating ${avgLabel}`; + avgEl.textContent = translated; avgEl.classList.remove('movie-rating-sub--empty'); } else { avgEl.textContent = ''; @@ -335,7 +353,12 @@ window.addEventListener('DOMContentLoaded', async function () { if (lastUpdatedIso) { const d = new Date(lastUpdatedIso); if (!Number.isNaN(d.getTime())) { - lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }); + const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en'); + try { + lastUpdatedEl.textContent = d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }); + } catch (_) { + lastUpdatedEl.textContent = d.toLocaleString(); + } } else { lastUpdatedEl.textContent = '—'; } @@ -347,7 +370,12 @@ window.addEventListener('DOMContentLoaded', async function () { const total = Number(summary?.totalMoviesCount) || 0; const watched = Number(summary?.watchedMoviesCount) || 0; const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0'; - if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`; + if (ratioEl) { + const txt = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: watched, total: total, percent: ratioPct }) + : `Watched: ${watched} / ${total} (${ratioPct}%)`; + ratioEl.innerText = txt; + } } const activeYear = await fetchActiveYear(); @@ -361,16 +389,18 @@ window.addEventListener('DOMContentLoaded', async function () { const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`); if (oscarDayMonthEl) { try { - oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en'); + oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); } catch (_) { - oscarDayMonthEl.textContent = '15 mars'; + oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15'; } } if (timeLeftEl) { const currentDate = new Date(); const timeDifference = fallbackDate - currentDate; const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - timeLeftEl.textContent = `${daysLeft} jours`; + const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`; + timeLeftEl.textContent = daysLabel; } fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => { @@ -380,14 +410,16 @@ window.addEventListener('DOMContentLoaded', async function () { if (oscarDayMonthEl) { try { - oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en'); + oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); } catch (_) {} } if (timeLeftEl) { const currentDate = new Date(); const timeDifference = targetDate - currentDate; const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); - timeLeftEl.textContent = `${daysLeft} jours`; + const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`; + timeLeftEl.textContent = daysLabel; } }).catch(() => {}); @@ -473,8 +505,11 @@ window.addEventListener('DOMContentLoaded', async function () { const totalMoviesCount = movies.length; const watchedMoviesCount = watchedMoviesInYear.length; const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0'; - const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`; - document.getElementById('watched-ratio').innerText = ratioText; + const ratioText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct }) + : `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`; + const ratioEl = document.getElementById('watched-ratio'); + if (ratioEl) ratioEl.innerText = ratioText; if (moviesList) moviesList.innerHTML = ''; @@ -501,6 +536,11 @@ window.addEventListener('DOMContentLoaded', async function () { Number.isFinite(userRatingAvg) && userRatingAvg > 0 ? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}` : ''; + const avgHtml = userRatingText + ? ((window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.userRating', { rating: userRatingText }) + : `User rating ${userRatingText}`) + : ''; const userRatingOwn = Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : ''; const starsHtml = Array.from({ length: 5 }, (_, idx) => { @@ -531,10 +571,13 @@ window.addEventListener('DOMContentLoaded', async function () { // Hide "resume/progress" once we're basically at the credits. // Backend uses 95% as the "credit roll" threshold too. if (pct < 95) { + const resumeText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.resumeAt', { time: `${formatClock(time)}` }) + : `Resume at ${formatClock(time)}`; progressHtml = `
- Reprendre à ${formatClock(time)} + ${resumeText} ${pct.toFixed(0)}%
@@ -544,9 +587,12 @@ window.addEventListener('DOMContentLoaded', async function () { `; } } else { + const resumeText = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.resumeAt', { time: `${formatClock(time)}` }) + : `Resume at ${formatClock(time)}`; progressHtml = `
- Reprendre à ${formatClock(time)} + ${resumeText}
`; } @@ -554,6 +600,12 @@ window.addEventListener('DOMContentLoaded', async function () { } const camBadge = movie?.cam ? 'CAM' : ''; + const watchedBadge = isChecked + ? `${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.watched') : 'WATCHED'}` + : ''; + const noSourceHtml = !isClickable + ? `
${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.noSource') : 'No source'}
` + : ''; const posterBlock = `
${camBadge} @@ -566,7 +618,7 @@ window.addEventListener('DOMContentLoaded', async function () { movieDiv.setAttribute('data-imdb-id', movie.imdb_id); movieDiv.innerHTML = ` - ${!isClickable ? '
Aucune source
' : ''} + ${noSourceHtml}
- Catégories nominées + ${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.nominatedCategories') : 'Nominated categories'}
${movie.category}
@@ -615,7 +667,7 @@ window.addEventListener('DOMContentLoaded', async function () {
- ${isChecked ? `
Regardé le: ${watchedDate}
` : ''} + ${isChecked ? `
${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.watchedOn') : 'Watched on:'} ${watchedDate}
` : ''} ${progressHtml}
@@ -658,8 +710,8 @@ window.addEventListener('DOMContentLoaded', async function () { }); pageLoader.done(); } catch (e) { - setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.'); - pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.'); + setLoadError(moviesList, t('movies.errorLoadingList', 'Unable to load movie list. Try again in a moment.')); + pageLoader.fail(t('movies.errorLoading', 'Unable to load movies. Check your connection and try again.')); } document.getElementById('log-off').addEventListener('click', function () { diff --git a/public/js/app_picks.js b/public/js/app_picks.js index f558418..9003e60 100644 --- a/public/js/app_picks.js +++ b/public/js/app_picks.js @@ -2,9 +2,9 @@ 'use strict'; function createPageLoader(options = {}) { - const title = String(options.title || 'Chargement…'); + const title = String(options.title || t('common.loading', 'Loading…')); // Subtitle kept for backwards-compat with callers, but we no longer render text in the UI. - const subtitle = String(options.subtitle || 'Préparation de la page…'); + const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…')); let progress = 0; let removed = false; @@ -120,6 +120,21 @@ return { setProgress, setSubtitle, done, fail }; } + function t(key, fallback, params) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params || {}); + } + } catch (_) {} + let out = (typeof fallback === 'string') ? fallback : String(key); + if (params && typeof params === 'object') { + Object.keys(params).forEach((k) => { + out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k])); + }); + } + return out; + } + const token = localStorage.getItem('auth_token'); if (!token) { window.location.href = '/'; @@ -198,7 +213,7 @@ // Use active year if not set if (!currentYear) { currentYear = await fetchActiveYear(); - document.title = `Pool Oscars (${currentYear}) - Mes picks`; + document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear }); } if (pageLoader) pageLoader.setProgress(18); @@ -225,10 +240,12 @@ document.getElementById('current-year').textContent = currentYear; if (categories.length === 0) { + const noCats = t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear }); + const adminHint = t('picks.adminMustImportCategories', 'An administrator must first import the categories.'); document.getElementById('categories-container').innerHTML = `
- Aucune catégorie trouvée pour l'année ${currentYear}. - Un administrateur doit d'abord importer les catégories. + ${noCats} + ${adminHint}
`; if (pageLoader) pageLoader.done(); @@ -243,7 +260,7 @@ if (pageLoader) pageLoader.setProgress(85); } catch (err) { console.error('Error fetching categories:', err); - showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger'); + showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger'); if (pageLoader) pageLoader.fail(); } } @@ -287,7 +304,7 @@ if (!container) return; if (categories.length === 0) { - container.innerHTML = '
Aucune catégorie disponible
'; + container.innerHTML = `
${t('picks.noCategoriesAvailable', 'No categories available')}
`; return; } @@ -297,9 +314,9 @@ # - Catégorie - Choix - Nommés + ${t('picks.category', 'Category')} + ${t('picks.choice', 'Choice')} + ${t('picks.nominees', 'Nominees')} @@ -314,7 +331,7 @@ ${selectedNominee} - ` : 'Aucun choix'} + ` : `${t('picks.noChoice', 'No choice')}`}
@@ -386,7 +403,7 @@ } catch (err) { console.error('Auto-save failed:', err); // Show error alert only on failure - showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger'); + showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger'); } finally { isSaving = false; } @@ -413,7 +430,7 @@ // Skip confirmation dialog in silent mode (auto-save) if (!silent && picks.length < categories.length) { - if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) { + if (!confirm(t('picks.incompleteSelection', `You have selected only ${picks.length} out of ${categories.length} categories. Do you want to save anyway?`, { selected: picks.length, total: categories.length }))) { return; } } @@ -438,18 +455,18 @@ const data = await res.json(); if (!silent) { - showAlert('Vos choix ont été enregistrés avec succès!', 'success'); + showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success'); } } catch (err) { console.error('Error submitting picks:', err); - showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger'); + showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger'); } } // Event listeners document.getElementById('load-my-picks')?.addEventListener('click', () => { loadMyPicks(); - showAlert('Choix rechargés', 'info'); + showAlert(t('picks.reloaded', 'Picks reloaded'), 'info'); }); // Winners & Scores functionality (admin only) @@ -480,7 +497,7 @@ await fetchScores(); } catch (err) { console.error('Error fetching winners categories:', err); - showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger'); + showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger'); } } @@ -489,7 +506,7 @@ if (!container) return; if (winnersCategories.length === 0) { - container.innerHTML = '
Aucune catégorie trouvée pour cette année.
'; + container.innerHTML = `
${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}
`; return; } @@ -506,9 +523,9 @@ # - Catégorie - Sélection - Gagnant + ${t('picks.categoryHeader', 'Category')} + ${t('picks.selectionHeader', 'Selection')} + ${t('picks.winnerHeader', 'Winner')} @@ -559,13 +576,18 @@ throw new Error(error.message || 'Failed to update winner'); } - showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', 'success'); + showAlert( + winnerName + ? t('picks.winnerMarked', 'Winner marked successfully') + : t('picks.winnerDeleted', 'Winner deleted'), + 'success' + ); await fetchWinnersCategories(); // Auto-refresh scores after marking/clearing a winner (silent mode) await calculateScores(true); } catch (err) { console.error('Error updating winner:', err); - showAlert('Erreur: ' + err.message, 'danger'); + showAlert(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger'); } }); }); @@ -580,7 +602,7 @@ const btn = document.getElementById('calculate-scores'); if (btn) { btn.disabled = true; - btn.textContent = 'Calcul en cours...'; + btn.textContent = t('picks.calculatingScores', 'Calculating scores…'); } const res = await fetch('/api/picks/calculate-scores', { @@ -598,7 +620,7 @@ if (error.message && error.message.includes('No winners marked')) { if (!silent) { // Only show as info/warning, not error - showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info'); + showAlert(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info'); } // Still try to fetch existing scores await fetchScores(); @@ -609,19 +631,22 @@ const data = await res.json(); if (!silent) { - showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success'); + showAlert( + t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }), + 'success' + ); } await fetchScores(); } catch (err) { console.error('Error calculating scores:', err); if (!silent) { - showAlert('Erreur: ' + err.message, 'danger'); + showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger'); } } finally { const btn = document.getElementById('calculate-scores'); if (btn) { btn.disabled = false; - btn.textContent = 'Calculer les scores'; + btn.textContent = t('picks.calculateScores', 'Calculate scores'); } } } @@ -653,7 +678,7 @@ console.error('Error fetching scores:', err); const container = document.getElementById('scores-container'); if (container) { - container.innerHTML = '
Erreur lors du chargement des scores.
'; + container.innerHTML = `
${t('picks.errorLoadingScores', 'Error loading scores.')}
`; } } } @@ -684,7 +709,7 @@ }).sort((a, b) => b.actualScore - a.actualScore); if (validScores.length === 0) { - container.innerHTML = '
Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.
'; + container.innerHTML = `
${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}
`; return; } @@ -694,7 +719,7 @@ # - Utilisateur + ${t('picks.userHeader', 'User')} Score Total Actions @@ -714,7 +739,7 @@ ${totalCategories} @@ -743,10 +768,10 @@ const modalTitle = document.getElementById('userPicksModalLabel'); const modalContent = document.getElementById('user-picks-modal-content'); - modalTitle.textContent = `Choix de ${score.userName}`; + modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName }); if (!score.pickDetails || score.pickDetails.length === 0) { - modalContent.innerHTML = '
Aucun détail disponible.
'; + modalContent.innerHTML = `
${t('picks.noDetailsAvailable', 'No details available.')}
`; modal.show(); return; } @@ -763,11 +788,11 @@
-
Résumé
+
${t('picks.summaryTitle', 'Summary')}
- ${correctPicks.length} correct(s) | - ${incorrectPicks.length} incorrect(s) | - ${score.pickDetails.length} total + ${correctPicks.length} ${t('picks.correctCountLabel', 'correct')} | + ${incorrectPicks.length} ${t('picks.incorrectCountLabel', 'incorrect')} | + ${score.pickDetails.length} ${t('picks.totalCountLabel', 'total')}
@@ -781,7 +806,7 @@
✓ - Corrects (${correctPicks.length}) + ${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
${correctPicks.map(pick => ` @@ -804,7 +829,7 @@
✗ - Incorrects (${incorrectPicks.length}) + ${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
${incorrectPicks.map(pick => ` @@ -814,13 +839,13 @@ ${pick.categoryNumber}. ${pick.categoryName}
- ✗ Choix: ${pick.selectedNominee} + ✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}
${pick.correctWinner ? `
- ✓ Gagnant: ${pick.correctWinner} + ✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}
- ` : '
Aucun gagnant marqué
'} + ` : `
${t('picks.noWinnerMarkedShort', 'No winner marked')}
`}
@@ -832,7 +857,7 @@ ${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
- Aucun choix à afficher. + ${t('picks.noChoicesToShow', 'No picks to show.')}
` : ''} `; @@ -843,7 +868,7 @@ // Event listeners for winners tab document.getElementById('refresh-winners')?.addEventListener('click', () => { fetchWinnersCategories(); - showAlert('Données rafraîchies', 'info'); + showAlert(t('picks.dataRefreshed', 'Data refreshed'), 'info'); }); // Load scores when scores tab is shown @@ -867,19 +892,19 @@ // Refresh scores button document.getElementById('refresh-scores')?.addEventListener('click', async () => { await fetchScores(); - showAlert('Scores rafraîchis', 'info'); + showAlert(t('picks.scoresRefreshed', 'Scores refreshed'), 'info'); }); // Initial load window.addEventListener('DOMContentLoaded', async function() { const pageLoader = createPageLoader({ - title: 'Chargement des picks', - subtitle: 'Récupération des données…' + title: t('picks.loadingTitle', 'Loading picks'), + subtitle: t('picks.loadingSubtitle', 'Fetching data…') }); try { currentYear = await fetchActiveYear(); - document.title = `Pool Oscars (${currentYear}) - Mes choix`; + document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear }); pageLoader.setProgress(12); await fetchCategories(pageLoader); diff --git a/public/js/app_player.js b/public/js/app_player.js index 25105f0..5b33cc6 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -7,6 +7,20 @@ function decodeJwt(token) { return JSON.parse(atob(padded)); } +// i18n helper function +function t(key, fallback = '', params = {}) { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params); + } + let result = fallback; + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + result = result.replace(`{${k}}`, String(params[k])); + }); + } + return result; +} + function createPageLoader(options = {}) { const title = String(options.title || 'Chargement…'); let progress = 0; @@ -262,19 +276,19 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { pendingRestoreTime = clamped; restoreInProgress = true; try { videoEl.currentTime = clamped; } catch (_) {} - lastSaveStatus = `Reprise · ${formatClock(clamped)}`; - setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` }); + lastSaveStatus = t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) }); + setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) }) }); } else { pendingRestoreTime = null; restoreInProgress = false; - lastSaveStatus = 'Début'; - setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' }); + lastSaveStatus = t('player.beginning', 'Start'); + setProgressUi({ state: 'info', text: t('player.beginning', 'Start'), showText: true, ariaText: t('player.playFromBeginning', 'Playing from the beginning') }); } return true; } function scheduleRestore() { - if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); + if (!restored) setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') }); void tryRestoreProgress(); } @@ -315,18 +329,18 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) { } catch (_) {} // Icon-only saving indicator (avoid noisy text updates). - setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' }); + setProgressUi({ state: 'saving', text: '', showText: false, ariaText: t('player.saving', 'Saving…') }); const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, keepalive: !!keepalive }); if (ok === false) { // Token expired mid-playback; stop spamming requests and prompt user. stopInterval(); try { localStorage.removeItem('auth_token'); } catch (_) {} - lastSaveStatus = 'Connexion requise pour sauvegarder.'; - setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus }); - showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning'); + lastSaveStatus = t('player.connectionRequired', 'Connection required for saving.'); + setProgressUi({ state: 'warning', text: t('player.connection', 'Connection'), showText: true, ariaText: lastSaveStatus }); + showAlertVariant(t('player.sessionExpired', 'Session expired. Please reconnect to continue saving progress.'), 'warning'); return; } - lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`; + lastSaveStatus = t('player.saved', 'Saved at {time}.', { time: formatClock(snap.time) }); setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus }); if (savedUiTimer) window.clearTimeout(savedUiTimer); savedUiTimer = window.setTimeout(() => { @@ -574,11 +588,11 @@ function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount, }); const parts = []; - if (avg && count > 0) { - parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`); - } else { - parts.push('Note utilisateurs: —'); - } + const ratingLabel = (avg && count > 0) ? `${avg.toFixed(1)} (${count})` : '—'; + const translatedRating = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('movies.userRating', { rating: ratingLabel }) + : `User rating ${ratingLabel}`; + parts.push(translatedRating); metaEl.textContent = parts.join(' • '); } @@ -1250,7 +1264,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) { } window.onload = async function () { - const pageLoader = createPageLoader({ title: 'Chargement du lecteur' }); + const pageLoader = createPageLoader({ title: t('player.loadingPlayer', 'Loading player…') }); // Basic auth UX: require a valid local token (like the rest of the app). const token = localStorage.getItem('auth_token'); @@ -1373,13 +1387,13 @@ window.onload = async function () { const videoEl = document.getElementById('video'); if (videoEl && !videoEl.classList.contains('d-none')) { applySubtitleTrack({ videoEl, movie, token }); - setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); + setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') }); const userId = normalizeUserId(decoded); if (userId) { setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id }); } else { // Avoid a "stuck" progress state if the token payload is unexpected. - setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." }); + setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: t('player.unableToIdentifyUser', "Unable to identify user for saving progress.") }); } } else { setProgressUi({ state: 'info', text: '—', showText: true }); diff --git a/public/js/app_register.js b/public/js/app_register.js index 93845ff..eead010 100644 --- a/public/js/app_register.js +++ b/public/js/app_register.js @@ -2,6 +2,22 @@ function decodeJwt(token) { return JSON.parse(atob(token.split('.')[1])); } +function translateApiMessage(messageKey, message, fallbackKey, fallbackText) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + if (messageKey) return window.i18n.t(messageKey); + const map = { + 'Mauvais nom de chien': 'auth.dogAnswerIncorrect', + "L'utilisateur s'est enregistré avec succès !": 'auth.registrationSuccess', + 'User registered successfully!': 'auth.registrationSuccess', + }; + if (message && map[message]) return window.i18n.t(map[message]); + if (fallbackKey) return window.i18n.t(fallbackKey); + } + } catch (_) {} + return message || fallbackText || ''; +} + async function applyActiveOscarYearToTitle() { const base = 'Pool Oscars'; const current = String(document.title || base); @@ -92,7 +108,12 @@ initPage(); if (res.ok) { const data = await res.json(); // Show success message in the modal - responseMessageElement.textContent = data.message; + responseMessageElement.textContent = translateApiMessage( + data?.messageKey, + data?.message, + 'auth.registrationSuccess', + 'User registered successfully!' + ); // Show the modal modal.show(); @@ -104,7 +125,11 @@ initPage(); } else { const error = await res.json(); // Show error message in the modal - responseMessageElement.textContent = `Error: ${error.message}`; + const prefix = (window.i18n && typeof window.i18n.t === 'function') + ? window.i18n.t('common.errorPrefix') + : 'Error:'; + const msg = translateApiMessage(error?.messageKey, error?.message, 'common.errorOccurred', 'An error occurred'); + responseMessageElement.textContent = `${prefix} ${msg}`.trim(); // Show the modal modal.show(); diff --git a/public/js/app_theme.js b/public/js/app_theme.js index 77b59ce..a575c40 100644 --- a/public/js/app_theme.js +++ b/public/js/app_theme.js @@ -7,6 +7,21 @@ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const API_ENDPOINT = '/api/users/theme'; + function t(key, fallback, params) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params || {}); + } + } catch (_) {} + let out = (typeof fallback === 'string') ? fallback : String(key); + if (params && typeof params === 'object') { + Object.keys(params).forEach((k) => { + out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k])); + }); + } + return out; + } + function normalizeTheme(value) { return value === 'light' || value === 'dark' ? value : null; } @@ -111,9 +126,14 @@ buttons.forEach((button) => { button.setAttribute('aria-checked', String(isDark)); button.dataset.theme = theme; - const label = isDark ? 'Mode sombre' : 'Mode clair'; - const sourceLabel = source === 'system' ? ' (système)' : ''; - button.setAttribute('title', `${label}${sourceLabel} — Shift+clic pour système`); + const label = isDark + ? t('theme.darkMode', 'Dark mode') + : t('theme.lightMode', 'Light mode'); + const sourceLabel = source === 'system' + ? t('theme.systemSuffix', ' (system)') + : ''; + const hint = t('theme.systemHint', '— Shift+click for system'); + button.setAttribute('title', `${label}${sourceLabel} ${hint}`.trim()); }); } diff --git a/public/js/app_user-stat.js b/public/js/app_user-stat.js index 538e2c7..38456b5 100644 --- a/public/js/app_user-stat.js +++ b/public/js/app_user-stat.js @@ -1,7 +1,7 @@ function createPageLoader(options = {}) { - const title = String(options.title || 'Chargement…'); + const title = String(options.title || t('common.loading', 'Loading…')); // Subtitle kept for backwards-compat with callers, but we no longer render text in the UI. - const subtitle = String(options.subtitle || 'Préparation de la page…'); + const subtitle = String(options.subtitle || t('stats.preparingPage', 'Preparing page…')); let progress = 0; let removed = false; @@ -118,10 +118,31 @@ function createPageLoader(options = {}) { return { setProgress, setSubtitle, done, fail }; } +function getLocale() { + return (window.i18n && typeof window.i18n.getLanguage === 'function') + ? window.i18n.getLanguage() + : (document.documentElement.lang || 'en'); +} + +function t(key, fallback, params) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params || {}); + } + } catch (_) {} + let out = (typeof fallback === 'string') ? fallback : String(key); + if (params && typeof params === 'object') { + Object.keys(params).forEach((k) => { + out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k])); + }); + } + return out; +} + window.addEventListener('DOMContentLoaded', async function () { const pageLoader = createPageLoader({ - title: 'Chargement des statistiques', - subtitle: 'Récupération des données…' + title: t('stats.loadingStats', 'Loading statistics'), + subtitle: t('stats.fetchingData', 'Fetching data…') }); const token = localStorage.getItem('auth_token'); @@ -173,7 +194,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (!list.length) { tbody.innerHTML = ` - Aucun gagnant défini. + ${t('stats.noWinnersDefined', 'No winners defined.')} `; return; @@ -187,7 +208,7 @@ window.addEventListener('DOMContentLoaded', async function () { const arr = byYear.get(String(y)) || []; arr.push({ year: y, - name: w?.name || '(utilisateur supprimé)', + name: w?.name || t('stats.deletedUser', '(deleted user)'), points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points), }); byYear.set(String(y), arr); @@ -215,7 +236,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (sorted.length > 1) { const small = document.createElement('div'); small.className = 'text-muted small'; - small.textContent = 'égalité'; + small.textContent = t('stats.tie', 'tie'); tdYear.appendChild(document.createElement('br')); tdYear.appendChild(small); } @@ -227,7 +248,7 @@ window.addEventListener('DOMContentLoaded', async function () { const name = document.createElement('span'); name.className = 'fw-semibold'; - name.textContent = w?.name || '(utilisateur supprimé)'; + name.textContent = w?.name || t('stats.deletedUser', '(deleted user)'); row.appendChild(name); const pts = w.points === null || Number.isNaN(w.points) ? null : w.points; @@ -254,7 +275,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (!completions || typeof completions !== 'object') { tbody.innerHTML = ` - Impossible de charger les finisseurs 100%. + ${t('stats.unableToLoadCompleters', 'Unable to load 100% completers.')} `; return; @@ -267,7 +288,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (!years.length) { tbody.innerHTML = ` - Aucune année trouvée. + ${t('stats.noYearsFound', 'No years found.')} `; return; @@ -311,9 +332,9 @@ window.addEventListener('DOMContentLoaded', async function () { let dateStr = ''; if (u?.completedAt) { const date = new Date(u.completedAt); - dateStr = ` (${date.toLocaleDateString('fr-CA')})`; + dateStr = ` (${date.toLocaleDateString(getLocale())})`; } - badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`; + badge.textContent = `${rankPrefix}${u?.name || t('stats.unnamed', '(unnamed)')}${dateStr}`; wrap.appendChild(badge); }); tdNames.appendChild(wrap); @@ -359,7 +380,7 @@ window.addEventListener('DOMContentLoaded', async function () { try { pageLoader.setProgress(12); - // Modal UI (Films regardés) + // Modal UI (watched movies) const moviesModalEl = document.getElementById('moviesModal'); const movieGridEl = document.getElementById('movie-grid'); const moviesSummaryEl = document.getElementById('movies-summary'); @@ -383,7 +404,7 @@ window.addEventListener('DOMContentLoaded', async function () { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; try { - return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' }); + return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', day: 'numeric' }); } catch (_) { return d.toLocaleDateString(); } @@ -413,7 +434,9 @@ window.addEventListener('DOMContentLoaded', async function () { const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : [])); const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0; - moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film'; + moviesSummaryEl.textContent = total + ? t('stats.filmCount', '{count} movie{plural}', { count: total, plural: total > 1 ? 's' : '' }) + : t('stats.noFilms', '0 movies'); movieGridEl.innerHTML = ''; if (!sorted.length) { @@ -452,7 +475,7 @@ window.addEventListener('DOMContentLoaded', async function () { item.classList.add('stats-movie-item--clickable'); item.setAttribute('role', 'link'); item.setAttribute('tabindex', '0'); - item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`); + item.setAttribute('aria-label', vodLink ? t('stats.openLink', 'Open link') + ': ' + title : t('stats.openPlayer', 'Open player') + ': ' + title); } const poster = document.createElement('img'); @@ -523,7 +546,7 @@ window.addEventListener('DOMContentLoaded', async function () { imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`; imdbLink.target = '_blank'; imdbLink.rel = 'noopener noreferrer'; - imdbLink.setAttribute('aria-label', 'Voir sur IMDb'); + imdbLink.setAttribute('aria-label', (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'); const imdbIcon = document.createElement('span'); imdbIcon.className = 'imdb-icon'; @@ -555,7 +578,7 @@ window.addEventListener('DOMContentLoaded', async function () { if (watchedLabel) { const d = document.createElement('span'); d.className = 'text-muted'; - d.textContent = `Vu le ${watchedLabel}`; + d.textContent = t('stats.watchedOn', 'Watched on {date}', { date: watchedLabel }); meta.appendChild(d); } @@ -568,7 +591,7 @@ window.addEventListener('DOMContentLoaded', async function () { details.className = 'stats-movie-details'; const summary = document.createElement('summary'); - summary.textContent = 'Détails'; + summary.textContent = t('common.details', 'Details'); details.appendChild(summary); const body = document.createElement('div'); @@ -592,8 +615,8 @@ window.addEventListener('DOMContentLoaded', async function () { if (moviesModalTitleEl) { const count = currentModalMovies.length; moviesModalTitleEl.textContent = currentModalUserName - ? `Films regardés — ${currentModalUserName} (${count})` - : `Films regardés (${count})`; + ? t('stats.watchedMoviesUser', 'Watched movies — {user} ({count})', { user: currentModalUserName, count: count }) + : t('stats.watchedMovies', 'Watched movies ({count})', { count: count }); } renderMoviesModal(); @@ -619,10 +642,10 @@ window.addEventListener('DOMContentLoaded', async function () { pageLoader.setProgress(20); const activeYear = await fetchActiveYear(); if (activeYear) { - document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`; + document.title = `Pool Oscars (${activeYear}) - ${t('stats.pageTitle', 'User Statistics')}`; // Keep the main heading clean; show the year in the "Classement" tab instead. const h2 = document.querySelector('h2'); - if (h2) h2.textContent = 'Statistiques des utilisateurs'; + if (h2) h2.textContent = t('stats.pageTitle', 'User Statistics'); const badge = document.getElementById('stats-active-year-badge'); if (badge) { badge.textContent = String(activeYear); @@ -656,7 +679,7 @@ window.addEventListener('DOMContentLoaded', async function () { const userTableBody = document.getElementById('user-table-body'); // Check visibility config for Bon picks column - let showBonPicksColumn = true; + let showBonPicksColumn = false; try { const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' }); if (visibilityRes.ok) { @@ -694,7 +717,8 @@ window.addEventListener('DOMContentLoaded', async function () { // Update Films column header with total const filmsHeader = document.getElementById('films-header'); if (filmsHeader && totalMovies > 0) { - filmsHeader.innerHTML = `Films (${totalMovies})`; + const filmsLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('stats.films') : 'Movies'; + filmsHeader.innerHTML = `${filmsLabel} (${totalMovies})`; } stats.forEach((userStat, index) => { @@ -751,7 +775,7 @@ window.addEventListener('DOMContentLoaded', async function () { const statsError = document.getElementById('stats-error'); if (table) table.classList.add('d-none'); if (statsError) { - statsError.textContent = "Impossible de charger le classement pour le moment."; + statsError.textContent = t('stats.unableToLoadRanking', 'Unable to load the ranking right now.'); statsError.classList.remove('d-none'); } // Still attempt to render these sections even if stats fail (best effort) diff --git a/public/js/app_version.js b/public/js/app_version.js index 618788b..dff41e7 100644 --- a/public/js/app_version.js +++ b/public/js/app_version.js @@ -3,8 +3,20 @@ const res = await fetch('/api/version'); if (res.ok) { const data = await res.json(); + if (window.i18n && typeof window.i18n.init === 'function') { + try { + await window.i18n.init(); + } catch (_) {} + } + const hasI18n = window.i18n && typeof window.i18n.t === 'function'; + let message = data.message || ''; + if (data && data.configured === false) { + if (hasI18n) { + message = window.i18n.t('footer.versionNotConfigured'); + } + } // Update footer with the fetched version info - const footerText = `${data.date} - ${data.version} - ${data.message}`; + const footerText = `${data.date} - ${data.version} - ${message}`; document.getElementById('footer-text').textContent = footerText; } else { document.getElementById('footer-text').textContent = ''; diff --git a/public/js/app_view-picks.js b/public/js/app_view-picks.js index 257b744..8dc478a 100644 --- a/public/js/app_view-picks.js +++ b/public/js/app_view-picks.js @@ -41,6 +41,27 @@ let categories = []; let currentYear = new Date().getFullYear(); + function t(key, fallback, params) { + try { + if (window.i18n && typeof window.i18n.t === 'function') { + return window.i18n.t(key, params || {}); + } + } catch (_) {} + let out = (typeof fallback === 'string') ? fallback : String(key); + if (params && typeof params === 'object') { + Object.keys(params).forEach((k) => { + out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k])); + }); + } + return out; + } + + function getLocale() { + return (window.i18n && typeof window.i18n.getLanguage === 'function') + ? window.i18n.getLanguage() + : (document.documentElement.lang || 'en'); + } + function showAlert(message, type = 'info') { const container = document.getElementById('alert-container'); if (!container) return; @@ -91,7 +112,7 @@ renderPicks(); } catch (err) { console.error('Error fetching picks:', err); - showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger'); + showAlert(t('viewPicks.errorLoading', 'Error loading picks: {error}', { error: err.message }), 'danger'); } } @@ -102,7 +123,7 @@ if (allPicks.length === 0) { container.innerHTML = `
- Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}. + ${t('viewPicks.noPicks', 'No user has submitted picks for year {year} yet.', { year: currentYear })}
`; return; @@ -115,9 +136,19 @@ }); container.innerHTML = allPicks.map(pick => { - const userName = pick.userId?.name || 'Utilisateur inconnu'; + const userName = pick.userId?.name || t('viewPicks.unknownUser', 'Unknown user'); const userEmail = pick.userId?.email || ''; - const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—'; + let submittedDate = '—'; + if (pick.submittedAt) { + const d = new Date(pick.submittedAt); + if (!Number.isNaN(d.getTime())) { + try { + submittedDate = d.toLocaleString(getLocale(), { dateStyle: 'medium', timeStyle: 'short' }); + } catch (_) { + submittedDate = d.toLocaleString(); + } + } + } // Create a map of picks by category const picksMap = new Map(); @@ -141,7 +172,7 @@ ${cat.categoryNumber}. ${cat.categoryName}
${selected}
- ${isWinner ? 'Gagnant' : ''} + ${isWinner ? `${t('viewPicks.winnerBadge', 'Winner')}` : ''}
`; @@ -156,13 +187,13 @@ ${userEmail}
- Soumis le: ${submittedDate}
- ${pick.picks.length} / ${categories.length} catégories + ${t('viewPicks.submittedOn', 'Submitted on: {date}', { date: submittedDate })}
+ ${t('viewPicks.categoriesCount', '{count} / {total} categories', { count: pick.picks.length, total: categories.length })}
- ${picksList || '
Aucun choix enregistré
'} + ${picksList || `
${t('viewPicks.noPicks2', 'No picks recorded')}
`}
`; @@ -172,7 +203,7 @@ // Event listeners document.getElementById('refresh-picks')?.addEventListener('click', () => { fetchAllPicks(); - showAlert('Choix rafraîchis', 'info'); + showAlert(t('viewPicks.picksRefreshed', 'Picks refreshed'), 'info'); }); // Initial load diff --git a/public/js/localization.js b/public/js/localization.js new file mode 100644 index 0000000..fc62853 --- /dev/null +++ b/public/js/localization.js @@ -0,0 +1,306 @@ +/** + * Lightweight i18n module for Oscar Pool + * Supports English (base) and French translations + */ +(function() { + 'use strict'; + + const STORAGE_KEY = 'app_language'; + const DEFAULT_LANG = 'en'; + const SUPPORTED_LANGS = ['en', 'fr']; + + let currentLang = DEFAULT_LANG; + let translations = {}; + let loadedLangs = new Set(); + let initPromise = null; + + /** + * Detect language from: localStorage → browser → default + */ + function detectLanguage() { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && SUPPORTED_LANGS.includes(stored)) { + return stored; + } + + const browserLang = (navigator.language || navigator.userLanguage || '').split('-')[0].toLowerCase(); + if (SUPPORTED_LANGS.includes(browserLang)) { + return browserLang; + } + + return DEFAULT_LANG; + } + + /** + * Load translation file for a language + */ + async function loadTranslations(lang) { + if (loadedLangs.has(lang)) return true; + + try { + const res = await fetch(`/locales/${lang}.json`, { cache: 'default' }); + if (!res.ok) { + console.warn(`i18n: Failed to load ${lang}.json (${res.status})`); + return false; + } + const data = await res.json(); + translations[lang] = data; + loadedLangs.add(lang); + return true; + } catch (err) { + console.warn(`i18n: Error loading ${lang}.json`, err); + return false; + } + } + + /** + * Translate a key with optional interpolation + * @param {string} key - Translation key (e.g., 'nav.logout') + * @param {Object} params - Optional parameters for interpolation + * @returns {string} - Translated string or key if not found + */ + function t(key, params = {}) { + if (!key) return ''; + + // Try current language, then fallback to default + let text = translations[currentLang]?.[key]; + if (text === undefined && currentLang !== DEFAULT_LANG) { + text = translations[DEFAULT_LANG]?.[key]; + } + if (text === undefined) { + // Return key as fallback (helps identify missing translations) + return key; + } + + // Simple interpolation: {varName} → value + if (params && typeof params === 'object') { + Object.keys(params).forEach(k => { + const regex = new RegExp(`\\{${k}\\}`, 'g'); + text = text.replace(regex, String(params[k])); + }); + } + + return text; + } + + /** + * Set the current language + */ + function setLanguage(lang) { + if (!SUPPORTED_LANGS.includes(lang)) { + console.warn(`i18n: Unsupported language "${lang}"`); + return false; + } + currentLang = lang; + localStorage.setItem(STORAGE_KEY, lang); + document.documentElement.lang = lang; + return true; + } + + /** + * Get the current language + */ + function getLanguage() { + return currentLang; + } + + /** + * Get list of supported languages + */ + function getSupportedLanguages() { + return [...SUPPORTED_LANGS]; + } + + /** + * Translate all elements with data-i18n attributes + */ + function translatePage() { + // Translate text content + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + if (!key) return; + + // Check for interpolation params + const paramsAttr = el.getAttribute('data-i18n-params'); + let params = {}; + if (paramsAttr) { + try { + params = JSON.parse(paramsAttr); + } catch (_) { + // Ignore invalid JSON + } + } + + const translated = t(key, params); + if (translated !== key) { + el.textContent = translated; + } + }); + + // Translate placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + if (!key) return; + const translated = t(key); + if (translated !== key) { + el.placeholder = translated; + } + }); + + // Translate aria-labels + document.querySelectorAll('[data-i18n-aria-label]').forEach(el => { + const key = el.getAttribute('data-i18n-aria-label'); + if (!key) return; + const translated = t(key); + if (translated !== key) { + el.setAttribute('aria-label', translated); + } + }); + + // Translate titles + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + if (!key) return; + const translated = t(key); + if (translated !== key) { + el.title = translated; + } + }); + + // Translate option values in selects + document.querySelectorAll('option[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + if (!key) return; + const translated = t(key); + if (translated !== key) { + el.textContent = translated; + } + }); + + // Mark body as ready (for CSS transitions) + document.body.classList.add('i18n-ready'); + } + + /** + * Update a single element's translation (for dynamic content) + */ + function translateElement(el, key, params = {}) { + if (!el || !key) return; + el.textContent = t(key, params); + } + + /** + * Fetch language setting from server (optional, for admin-set language) + */ + async function fetchServerLanguage() { + try { + const res = await fetch('/api/settings/language', { + method: 'GET', + cache: 'no-store' + }); + if (res.ok) { + const data = await res.json(); + if (data.language && SUPPORTED_LANGS.includes(data.language)) { + return data.language; + } + } + } catch (_) { + // Silently fail - use local detection + } + return null; + } + + /** + * Initialize i18n system + * @param {string} forceLang - Optional language to force (bypasses detection) + */ + async function init(forceLang = null) { + // Prevent multiple concurrent initializations + if (initPromise) return initPromise; + + initPromise = (async () => { + // Determine language to use + let lang = forceLang; + + if (!lang) { + // Try server setting first (if user is logged in and admin set a language) + const serverLang = await fetchServerLanguage(); + if (serverLang) { + lang = serverLang; + } else { + lang = detectLanguage(); + } + } + + // Always load default language as fallback + await loadTranslations(DEFAULT_LANG); + + // Load current language if different + if (lang !== DEFAULT_LANG) { + const loaded = await loadTranslations(lang); + if (!loaded) { + lang = DEFAULT_LANG; + } + } + + setLanguage(lang); + translatePage(); + + return lang; + })(); + + return initPromise; + } + + /** + * Change language and re-translate page + */ + async function changeLanguage(lang) { + if (!SUPPORTED_LANGS.includes(lang)) { + console.warn(`i18n: Unsupported language "${lang}"`); + return false; + } + + // Load translations if not already loaded + if (!loadedLangs.has(lang)) { + const loaded = await loadTranslations(lang); + if (!loaded) return false; + } + + setLanguage(lang); + translatePage(); + return true; + } + + /** + * Check if translations are loaded + */ + function isReady() { + return loadedLangs.size > 0; + } + + // Expose globally + window.i18n = { + t, + init, + setLanguage, + getLanguage, + changeLanguage, + getSupportedLanguages, + translatePage, + translateElement, + loadTranslations, + fetchServerLanguage, + isReady, + DEFAULT_LANG, + SUPPORTED_LANGS + }; + + // Auto-initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => init()); + } else { + // DOM already loaded + init(); + } +})(); diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..51f4a0b --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,584 @@ +{ + "nav.films": "Movies", + "nav.checklist": "Checklist", + "nav.stats": "Stats", + "nav.myPicks": "My picks", + "nav.admin": "Admin", + "nav.logout": "Log out", + "nav.viewAllPicks": "View All Picks", + + "common.loading": "Loading…", + "common.preparingPage": "Preparing page…", + "common.errorLoading": "Error loading.", + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.confirm": "Confirm", + "common.refresh": "Refresh", + "common.close": "Close", + "common.copy": "Copy", + "common.reset": "Reset", + "common.add": "Add", + "common.edit": "Edit", + "common.remove": "Remove", + "common.create": "Create", + "common.actions": "Actions", + "common.yes": "Yes", + "common.no": "No", + "common.all": "All", + "common.none": "None", + "common.search": "Search", + "common.filter": "Filter", + "common.year": "Year", + "common.name": "Name", + "common.email": "Email", + "common.status": "Status", + "common.date": "Date", + "common.title": "Title", + "common.description": "Description", + "common.networkError": "Network error", + "common.errorOccurred": "An error occurred", + "common.errorPrefix": "Error:", + "common.saving": "Saving...", + "common.success": "Success", + "common.toggleDarkMode": "Toggle dark mode", + "common.details": "Details", + "common.oscarPool": "Oscar Pool", + + "auth.login": "Login", + "auth.register": "Register", + "auth.email": "Email", + "auth.password": "Password", + "auth.name": "Name", + "auth.noAccount": "No account?", + "auth.hasAccount": "Already have an account?", + "auth.signUp": "Sign up", + "auth.signIn": "Sign in", + "auth.forgotPassword": "Forgot password?", + "auth.emailPlaceholder": "your@email.com", + "auth.namePlaceholder": "Your name", + "auth.passwordMin": "Minimum {min} characters", + "auth.passwordsDoNotMatch": "Passwords do not match", + "auth.confirmPassword": "Confirm password", + "auth.createAdminAccount": "Create admin account", + "auth.firstTimeSetup": "First time setup", + "auth.noAdminExists": "No administrator exists. Create the first admin account to get started.", + "auth.registrationStatus": "Registration status", + "auth.returnToHome": "Return to home page", + "auth.setupRequired": "Setup required", + "auth.appMustBeConfigured": "The application must first be configured by an administrator.", + "auth.goToSetup": "Go to setup page", + "auth.dogQuestion": "What is Cath & Nath's dog's name?", + "auth.errorCreatingAccount": "Error creating account", + "auth.networkErrorRetry": "Network error. Please try again.", + "auth.userNotFound": "User not found", + "auth.dogAnswerIncorrect": "Incorrect dog name.", + "auth.registrationSuccess": "User registered successfully!", + "auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.", + "auth.passwordMinLength": "Password must be at least 8 characters.", + "auth.loginFailed": "Login failed", + "auth.invalidCredentials": "Invalid credentials", + "auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.", + + "changePassword.title": "Change Password", + "changePassword.tempPassword": "Your account uses a temporary password. Please set a new password to continue.", + "changePassword.newPassword": "New password", + "changePassword.minChars": "Minimum 8 characters.", + "changePassword.passwordTooShort": "Password must be at least 8 characters.", + + "movies.title": "Movies", + "movies.lastUpdated": "Last movie update:", + "movies.watchedRatio": "Watched: {watched} / {total} ({percent}%)", + "movies.timeUntilOscars": "Time until the Oscars ({date} {year}) — {timeLeft}", + "movies.timeUntilOscarsLabel": "Time until the Oscars", + "movies.daysLeft": "{days} days", + "movies.hoursLeft": "{hours} hours", + "movies.unwatchedOnly": "Unwatched movies only", + "movies.clickToPlay": "Click the poster or title to open the player.", + "movies.watched": "WATCHED", + "movies.noSource": "No source", + "movies.nominatedCategories": "Nominated categories", + "movies.resumeAt": "Resume at {time}", + "movies.userRating": "User rating {rating}", + "movies.errorLoading": "Error loading.", + "movies.errorLoadingList": "Unable to load movie list. Try again in a moment.", + "movies.errorLoading": "Unable to load movies. Check your connection and try again.", + "movies.unableToLoadMovies": "Unable to load movies. Please try again later.", + "movies.unableToLoadMoviesNetwork": "Unable to load movies. Check your connection and try again.", + "movies.loadingMovies": "Loading movies", + "movies.fetchingData": "Fetching data…", + "movies.viewOnImdb": "View on IMDb", + + "checklist.title": "Checklist", + "checklist.seen": "Seen", + "checklist.filmTitle": "Title", + "checklist.date": "Date", + "checklist.watchedOn": "Watched on:", + "checklist.confirmRemoval": "Confirm removal", + "checklist.removeConfirmText": "Are you sure you want to remove {title} from your list?", + "checklist.moviesToWatchPerDay": "Movies to watch per day: {count} films", + "checklist.congratulations": "Congratulations! very nice 🎉🎉🎉", + "checklist.browserNoVideo": "Your browser does not support video.", + "checklist.loadingChecklist": "Loading checklist", + "checklist.errorLoading": "Error loading.", + "checklist.unableToLoad": "Unable to load checklist. Please try again later.", + "checklist.unableToLoadNetwork": "Unable to load checklist. Check your connection and try again.", + "checklist.march15": "March 15", + "checklist.loadingMovies": "Loading movies…", + + "picks.title": "My picks", + "picks.myPicks": "My picks", + "picks.pageTitleWithYear": "Oscar Pool ({year}) - My picks", + "picks.pageTitleChoicesWithYear": "Oscar Pool ({year}) - My picks", + "picks.scores": "Scores", + "picks.winners": "Winners", + "picks.adminBadge": "Admin", + "picks.year": "Year:", + "picks.progress": "Progress:", + "picks.reload": "Reload", + "picks.loadingCategories": "Loading categories…", + "picks.scoresCalculatedAfterWinners": "Scores will be calculated after marking winners.", + "picks.winnersMarked": "winners marked", + "picks.noCategory": "No category", + "picks.noChoice": "No choice", + "picks.category": "Category", + "picks.choice": "Choice", + "picks.nominees": "Nominees", + "picks.noCategoriesFound": "No categories found for year {year}", + "picks.adminMustImportCategories": "An administrator must first import the categories.", + "picks.selectionHeader": "Selection", + "picks.winnerHeader": "Winner", + "picks.noCategoriesAvailable": "No categories available", + "picks.winnerMarkedSuccess": "Winner marked successfully", + "picks.winnerRemoved": "Winner removed", + "picks.noWinnerMarked": "No winner marked. Scores will be calculated once winners are set.", + "picks.scoresCalculated": "Scores calculated! {count} user(s) evaluated.", + "picks.scoresRefreshed": "Scores refreshed", + "picks.calculatingScores": "Calculating scores…", + "picks.calculateScores": "Calculate scores", + "picks.dataRefreshed": "Data refreshed", + "picks.noScoresAvailable": "No scores available. Users must first submit their picks.", + "picks.details": "Details", + "picks.choicesOf": "Choices of {name}", + "picks.noDetailsAvailable": "No details available.", + "picks.summary": "Summary", + "picks.correct": "{count} correct", + "picks.incorrect": "{count} incorrect", + "picks.correctPicks": "Correct ({count})", + "picks.errorLoadingCategories": "Error loading categories: {error}", + "picks.preparingPage": "Preparing page…", + "picks.errorAutoSaving": "Error auto-saving: {error}", + "picks.incompleteSelection": "You have selected only {selected} out of {total} categories. Do you want to save anyway?", + "picks.successSaved": "Your picks have been saved successfully!", + "picks.errorSubmitting": "Error submitting picks: {error}", + "picks.reloaded": "Picks reloaded", + "picks.categoryHeader": "Category", + "picks.userHeader": "User", + "picks.winnerMarked": "Winner marked successfully", + "picks.winnerDeleted": "Winner deleted", + "picks.errorUpdatingWinner": "Error: {error}", + "picks.noWinnersMarked": "No winners marked. Scores will be calculated once winners are defined.", + "picks.scoresCalculated": "Scores calculated successfully! {count} user(s) evaluated.", + "picks.errorCalculatingScores": "Error: {error}", + "picks.errorLoadingScores": "Error loading scores.", + "picks.incorrectPicks": "Incorrect ({count})", + "picks.detailsButton": "Details", + "picks.userChoicesTitle": "Picks for {user}", + "picks.noDetailsAvailable": "No details available.", + "picks.summaryTitle": "Summary", + "picks.correctCountLabel": "correct", + "picks.incorrectCountLabel": "incorrect", + "picks.totalCountLabel": "total", + "picks.correctSectionTitle": "Correct ({count})", + "picks.incorrectSectionTitle": "Incorrect ({count})", + "picks.choiceLabel": "Choice", + "picks.winnerLabel": "Winner", + "picks.noWinnerMarkedShort": "No winner marked", + "picks.noChoicesToShow": "No picks to show.", + "picks.loadingTitle": "Loading picks", + "picks.loadingSubtitle": "Fetching data…", + "picks.yourChoice": "Choice: {choice}", + "picks.winner": "Winner: {winner}", + "picks.noWinnerMarkedYet": "No winner marked", + "picks.noPicksToShow": "No picks to show.", + "picks.partialSaveConfirm": "You have only selected {selected} out of {total} categories. Do you want to save anyway?", + "picks.savedSuccessfully": "Your picks have been saved successfully!", + "picks.errorSaving": "Error saving: {error}", + "picks.errorLoadingCategories": "Error loading categories: {error}", + "picks.autoSaveError": "Auto-save error: {error}", + "picks.picksReloaded": "Picks reloaded", + "picks.choiceDetails": "Choice details", + "picks.loadingPicks": "Loading picks", + + "viewPicks.title": "View All Picks", + "viewPicks.overview": "Overview of all user picks", + "viewPicks.usersSubmitted": "{count} user(s) have submitted their picks", + "viewPicks.loadingPicks": "Loading picks…", + "viewPicks.noPicks": "No user has submitted picks for year {year} yet.", + "viewPicks.picksRefreshed": "Picks refreshed", + "viewPicks.errorLoading": "Error loading picks: {error}", + "viewPicks.unknownUser": "Unknown user", + "viewPicks.categoriesCount": "{count} / {total} categories", + "viewPicks.noPicks2": "No picks recorded", + "viewPicks.submittedOn": "Submitted on: {date}", + "viewPicks.winnerBadge": "Winner", + + "player.title": "Player", + "player.loading": "Loading…", + "player.quality": "Quality", + "player.high": "High", + "player.low": "Low", + "player.rateThisFilm": "Rate this film", + "player.star": "{count} star", + "player.stars": "{count} stars", + "player.source": "Source", + "player.progress": "Progress", + "player.noVodLink": "No VOD link configured for this movie.", + "player.missingMovieId": "Missing movie id.", + "player.noPlayableSource": "No playable source configured for this movie.", + "player.beginning": "Start", + "player.resuming": "Resuming", + "player.resumingAt": "Resuming at {time}", + "player.preparingResume": "Preparing resume…", + "player.connectionRequired": "Connection required for saving.", + "player.connection": "Connection", + "player.playFromBeginning": "Playing from the beginning", + "player.sessionExpired": "Session expired. Please reconnect to continue saving progress.", + "player.saving": "Saving…", + "player.saved": "Saved at {time}.", + "player.loadingPlayer": "Loading player…", + "player.unableToIdentifyUser": "Unable to identify user for saving progress.", + + "common.continueQuestion": "Continue?", + + "stats.title": "Statistics", + "stats.pageTitle": "User Statistics", + "stats.ranking": "Ranking", + "stats.winners": "Winners 🏆", + "stats.completers": "100%", + "stats.rank": "#", + "stats.name": "Name", + "stats.films": "Movies", + "stats.correctPicks": "Correct picks", + "stats.points": "Points", + "stats.year": "Year", + "stats.winnersColumn": "Winner(s)", + "stats.completersHelp": "Users who have watched 100% (by year)", + "stats.noWinnersDefined": "No winners defined.", + "stats.noYearsFound": "No years found.", + "stats.unableToLoadRanking": "Unable to load the ranking right now.", + "stats.totalFilms": "Total movies", + "stats.names": "Names", + "stats.moviesWatched": "Movies watched", + "stats.noMoviesToShow": "No movies to show.", + + "stats.loading": "Loading…", + "stats.preparingPage": "Preparing page…", + "stats.loadingStats": "Loading statistics", + "stats.fetchingData": "Fetching data…", + "stats.deletedUser": "(deleted user)", + "stats.tie": "tie", + "stats.unableToLoadCompleters": "Unable to load 100% completers.", + "stats.unnamed": "(unnamed)", + "stats.openLink": "Open link", + "stats.openPlayer": "Open player", + "stats.watchedOn": "Watched on {date}", + "stats.watchedMoviesUser": "Watched movies — {user} ({count})", + "stats.watchedMovies": "Watched movies ({count})", + "stats.filmCount": "{count} movie{plural}", + "stats.noFilms": "0 movies", + + "admin.movies.noMovies": "No movies.", + + "admin.title": "Oscar Pool - Admin", + + "admin.tabs.settings": "Settings", + "admin.tabs.users": "Users", + "admin.tabs.modal100": "Modal 100%", + "admin.tabs.winners": "Winners", + "admin.tabs.oscarCategories": "Oscar Categories", + "admin.tabs.backupRestore": "Backup / Restore", + "admin.tabs.version": "Version", + + "admin.settings.info": "Global site settings (year, dates, player options).", + "admin.settings.oscarDate": "Oscar Date", + "admin.settings.visibility": "Visibility", + "admin.settings.language": "Language", + "admin.settings.oscarDateDesc": "Configure the ceremony date for the countdown (Films & Checklist). Default: March 15.", + "admin.settings.oscarDateDefaultShort": "— (default: 03-15)", + "admin.settings.year": "Year", + "admin.settings.applyActiveYear": "Apply active year", + "admin.settings.yearBasedOnMovies": "Based on years present in movies.", + "admin.settings.date": "Date", + "admin.settings.dateFormat": "Format: YYYY-MM-DD.", + "admin.settings.configuredDates": "Configured dates", + "admin.settings.pointsDesc": "Configure points awarded for watched movies and correct Oscar picks.", + "admin.settings.pointsPerMovie": "Points per movie watched", + "admin.settings.pointsPerMovieDesc": "Points awarded for each completed movie.", + "admin.settings.pointsPerCorrectPick": "Points per correct pick", + "admin.settings.pointsPerCorrectPickDesc": "Points awarded for each correct Oscar pick.", + "admin.settings.reload": "Reload", + "admin.settings.pointsCalculation": "Points calculation", + "admin.settings.total": "Total", + "admin.settings.moviesWatched": "Movies watched", + "admin.settings.correctPicks": "Correct picks", + "admin.settings.pointsSaved": "Configuration saved: {p} pts/movie, {q} pts/correct pick", + "admin.settings.noYearAddMovie": "No year (add a movie)", + "admin.settings.defaultBadge": "Default", + "admin.settings.pointsMustBeNonNegative": "Points must be zero or positive", + + "admin.visibility.showPicksFeatures": "Show \"Picks\" features", + "admin.visibility.showPicksFeaturesDesc": "Controls display of \"My picks\" button in navigation and \"Correct picks\" column in statistics.", + "admin.visibility.showSourceProgress": "Show \"Source\" + \"Progress\" (admins)", + "admin.visibility.showSourceProgressDesc": "Controls display of debug block in player.html (admins only).", + "admin.visibility.showVersionTab": "Show \"Version\" tab", + "admin.visibility.showVersionTabDesc": "Controls display of the \"Version\" tab in the admin panel.", + "admin.visibility.saved": "Configuration saved", + "admin.visibility.saveError": "Error saving configuration: {error}", + + "admin.language.siteLanguage": "Site language", + "admin.language.siteLanguageDesc": "Changes the language for all users. Page refresh required.", + + "admin.version.info": "Controls the version displayed in the footer (stored in DB). Click ✓ to activate a version, then check the preview.", + "admin.version.createNew": "Create a new version", + "admin.version.createNewDesc": "Adds an entry to the history (you can activate it immediately).", + "admin.version.version": "Version", + "admin.version.versionPlaceholder": "e.g.: 1.0.1", + "admin.version.versionExample": "E.g.: 1.2.3", + "admin.version.messageOptional": "Message (optional)", + "admin.version.messagePlaceholder": "e.g.: Hotfix login", + "admin.version.messageDesc": "Short & readable (shown in footer).", + "admin.version.create": "Create", + "admin.version.createAndActivate": "Create & activate", + "admin.version.created": "Version created.", + "admin.version.createdAndActivated": "Version created & activated.", + "admin.version.confirmDelete": "Delete this version?", + + "admin.backup.chooseFile": "Choose a backup file (.ndjson.gz).", + "admin.backup.restoreWithDrop": "RESTORE with DROP: all restored collections will be replaced.", + "admin.backup.restoreWithoutDrop": "RESTORE without DROP: high risk of duplicates/errors if the DB already contains data.", + + "admin.modal100.info": "Content displayed when a user has watched 100% of films.", + "admin.modal100.content": "Content", + "admin.modal100.contentDesc": "Edit the content then save. Preview is live (not saved until you save).", + "admin.modal100.titleLabel": "Title", + "admin.modal100.titlePlaceholder": "Congratulations! ...", + "admin.modal100.textOptional": "Text (optional)", + "admin.modal100.textPlaceholder": "Text displayed in the modal (line breaks preserved)", + "admin.modal100.textDesc": "Displayed as plain text (safe). Leave empty if not needed.", + "admin.modal100.htmlOptional": "HTML (optional)", + "admin.modal100.htmlDesc": "Displayed as HTML (server removes script tags and on* handlers).", + "admin.modal100.videoSrc": "Video src (optional)", + "admin.modal100.videoSrcDesc": "Relative path (e.g.: video/reward.mp4) or full URL. Empty = no video.", + "admin.modal100.browserNoVideo": "Your browser does not support video.", + "admin.modal100.textLabel": "Text", + "admin.modal100.textInfoShort": "displayed as-is (safe), line breaks preserved.", + "admin.modal100.htmlInfoShort": "server removes script tags and on* attributes.", + "admin.modal100.videoLabel": "Video", + "admin.modal100.videoInfoShort": "accepts relative path (e.g.: video/reward.mp4) or full URL.", + + "admin.users.usersCount": "user(s)", + "admin.users.addUser": "Add user", + "admin.users.name": "Name", + "admin.users.status": "Status", + "admin.users.actions": "Actions", + "admin.users.loading": "Loading list…", + "admin.users.resetPassword": "Reset password", + "admin.users.resetPasswordDesc": "Generates a temporary password. The user will need to change it on next login.", + "admin.users.tempPasswordOnce": "Temporary password (shown once)", + "admin.users.generate": "Generate", + "admin.users.deleteUser": "Delete user", + "admin.users.aboutToDelete": "You are about to delete:", + "admin.users.actionIrreversible": "This action is irreversible.", + "admin.users.typeEmailToConfirm": "To confirm, type the exact email:", + "admin.users.emailPlaceholder": "email@example.com", + "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/.", + "admin.movies.serverVideoFile": "Server video file (optional)", + "admin.movies.serverVideoFileDesc": "Relative path under VIDEO_FILES_DIR (default: public/video).", + "admin.movies.serverVideoFileLow": "Server video file (low quality, optional)", + "admin.movies.serverVideoFileLowDesc": "Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.", + "admin.movies.subtitles": "Subtitles (SRT, optional)", + "admin.movies.existingSubtitles": "Existing subtitles:", + "admin.movies.subtitlesNote": "Add one file per language; repeat upload for multiple subtitles.", + "admin.movies.removeAllSubtitles": "Remove all subtitles", + "admin.movies.removeAllSubtitlesDesc": "Deletes VTT/SRT files and resets language/label fields.", + "admin.movies.confirmRemoveSubtitle": "Delete this subtitle?", + "admin.movies.confirmDeleteSelected": "Delete {count} movie(s)? This action is irreversible.", + "admin.movies.subtitleLang": "Language (e.g.: fr, en)", + "admin.movies.subtitleLabel": "Label (displayed)", + "admin.movies.subtitleDefault": "Default", + "admin.movies.embedUrl": "Embed URL or iframe code", + "admin.movies.legacyVodLink": "Legacy VOD link (optional)", + "admin.movies.confirmRefreshOmdb": "Refresh {count} movie(s){yearLabel} from OMDb? This operation may overwrite info (title, plot, rating, poster, runtime).", + + "admin.backup.info": "MongoDB database backup and restore.", + "admin.backup.backup": "Backup", + "admin.backup.restore": "Restore", + "admin.backup.help": "Help", + "admin.backup.backups": "Backups", + "admin.backup.backupsDesc": "Format .ndjson.gz, stored on server.", + "admin.backup.createBackup": "Create backup", + "admin.backup.file": "File", + "admin.backup.size": "Size", + "admin.backup.modified": "Modified", + "admin.backup.openTabToLoad": "Open tab to load list…", + "admin.backup.restoreBackup": "Restore a backup", + "admin.backup.restoreDesc": "Restore a .ndjson.gz file. The drop option is recommended.", + "admin.backup.restoreWarning": "Restore can overwrite data. Make a backup first if needed.", + "admin.backup.backupFile": "Backup file", + "admin.backup.dropBeforeRestore": "Drop collections before restore (recommended)", + "admin.backup.tip": "Tip", + "admin.backup.tipDesc": "Use drop unless you know exactly what you're doing (otherwise risk of duplicates/errors).", + "admin.backup.howItWorks": "How it works", + "admin.backup.backupHowDesc": "creates a .ndjson.gz file (MongoDB export) and keeps it on the server.", + "admin.backup.download": "Download", + "admin.backup.downloadDesc": "retrieves the file locally (useful before a risky operation).", + "admin.backup.restoreHowDesc": "imports the file. With drop, restored collections are deleted first.", + "admin.backup.quickCheck": "Quick check", + "admin.backup.quickCheckDesc": "After restore, reload the page and check \"Films\", \"Checklist\" and \"Stats\".", + "admin.backup.confirmDelete": "Delete backup \"{name}\"?", + "admin.backup.creating": "Creating…", + "admin.backup.created": "Backup created: {name}", + "admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).", + "admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).", + + "admin.categories.info": "Create and manage categories manually.", + "admin.categories.manualCreation": "Manual creation", + "admin.categories.categoryCreated": "Category created successfully.", + "admin.categories.list": "List", + "admin.categories.createManually": "Create a category manually", + "admin.categories.createManuallyDesc": "Add a category and its nominees one by one.", + "admin.categories.categoryNumber": "Category number", + "admin.categories.categoryName": "Category name", + "admin.categories.nomineesOnePerLine": "Nominees (one per line)", + "admin.categories.nomineesDesc": "Enter one nominee per line.", + "admin.categories.note": "Note", + "admin.categories.noteDesc": "You can create multiple categories for the same year. The category number must be unique per year.", + "admin.categories.importedCategories": "Imported categories", + "admin.categories.manageExisting": "Manage existing categories", + "admin.categories.allYears": "All years", + "admin.categories.categoriesSelected": "category(ies) selected", + "admin.categories.editCategory": "Edit category", + "admin.categories.selectAllTitle": "Select all", + "admin.categories.table.number": "#", + "admin.categories.table.name": "Name", + "admin.categories.table.nominees": "Nominees", + "admin.categories.table.actions": "Actions", + "admin.categories.confirmDelete": "Are you sure you want to delete this category?", + "admin.categories.confirmDeleteMultiple": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.", + "admin.categories.noneFound": "No categories found.", + "admin.categories.noneFoundYear": "No categories found for year {year}.", + "admin.categories.fillAllFields": "Please fill in all fields", + "admin.categories.enterAtLeastOneNominee": "Please enter at least one nominee", + "admin.categories.numberBetween1And50": "Category number must be between 1 and 50", + "admin.categories.bulkDeleteConfirm": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.", + + "admin.winners.info": "Define one or more winners per year (tie possible).", + "admin.winners.manage": "Manage", + "admin.winners.addWinner": "Add a winner", + "admin.winners.addWinnerDesc": "You can add multiple winners for the same year (tie).", + "admin.winners.user": "User", + "admin.winners.removeNote": "To remove: use the list below.", + "admin.winners.pointsOptional": "Points (optional)", + "admin.winners.pointsPlaceholder": "e.g.: 42", + "admin.winners.pointsDesc": "Leave empty if not applicable.", + "admin.winners.deleteAll": "Delete all", + "admin.winners.currentWinners": "Current winners", + "admin.winners.globalView": "Global view (all years).", + "admin.winners.howItWorks": "How it works", + "admin.winners.multipleWinners": "A year can have multiple winners (tie).", + "admin.winners.tieLabel": "(tie)", + "admin.winners.tie": "(tie)", + "admin.winners.chooseUser": "Choose a user.", + "admin.winners.confirmDeleteAll": "Delete all winners for {year}?", + "admin.player.errorLoading": "Unable to load player settings.", + "admin.versions.updated": "Version updated.", + "admin.versions.deleted": "Version deleted.", + "admin.winners.pointsOptionalInfo": "Points are optional (display only).", + "admin.winners.removeInfo": "To remove a winner, use the \"Remove\" button in the \"Current winners\" list.", + "admin.winners.tipDesc": "If you change the site's active year, also check the winners for that year.", + "admin.winners.removedFor": "Winner removed for {year}.", + "admin.winners.reloaded": "Winners reloaded.", + "admin.winners.deletedFor": "Winner deleted for {year}.", + "admin.winners.addedFor": "Winner added for {year}.", + "admin.backup.deleted": "Backup deleted.", + "admin.backup.restoreComplete": "Restore complete.", + "admin.movies.deletedSuccessfully": "Movie(s) deleted successfully.", + "admin.movies.updatedFromOmdb": "{count} movie(s) updated from OMDb.", + "admin.movies.omdbResults": "{success} success, {errors} error(s). {details}", + "admin.movies.subtitleDeleted": "Subtitle deleted.", + "admin.modal100.saved": "100% modal saved.", + "admin.content.reloaded": "Content reloaded.", + "admin.oscars.invalidYear": "Invalid year. Expected: 2026", + "admin.oscars.dateSaved": "Oscars date saved for {year}: {date}", + "admin.oscars.dateCleared": "Oscars date cleared for {year} (reverted to default: March 15).", + "admin.player.settingsSaved": "Player settings saved.", + "admin.player.settingsReloaded": "Player settings reloaded.", + "admin.activeYear.applied": "Active year applied: {year}", + "admin.configuration.reloaded": "Configuration reloaded.", + "admin.categories.categoryNotFound": "Category not found.", + "admin.categories.categoryDeleted": "Category deleted.", + "admin.categories.categoryUpdated": "Category updated successfully.", + "admin.categories.deletedCount": "{count} category/categories deleted{errors}", + "admin.oscars.invalidYearShort": "Invalid year.", + "admin.movies.subtitleDeleted": "Subtitle deleted.", + "admin.categories.categoryInvalid": "Invalid category.", + + "footer.oscarPool": "Oscar Pool", + "footer.by": "by", + "footer.versionNotConfigured": "Now in full HD", + "footer.versionUnavailable": "Version unavailable (DB)." +} diff --git a/public/locales/fr.json b/public/locales/fr.json new file mode 100644 index 0000000..7830eef --- /dev/null +++ b/public/locales/fr.json @@ -0,0 +1,586 @@ +{ + "nav.films": "Films", + "nav.checklist": "Checklist", + "nav.stats": "Stats", + "nav.myPicks": "Mes picks", + "nav.admin": "Admin", + "nav.logout": "Déconnexion", + "nav.viewAllPicks": "Voir Tous les Choix", + + "common.loading": "Chargement…", + "common.preparingPage": "Préparation de la page…", + "common.errorLoading": "Erreur lors du chargement.", + "common.save": "Enregistrer", + "common.cancel": "Annuler", + "common.delete": "Supprimer", + "common.confirm": "Confirmer", + "common.refresh": "Rafraîchir", + "common.close": "Fermer", + "common.copy": "Copier", + "common.reset": "Reset", + "common.add": "Ajouter", + "common.edit": "Modifier", + "common.remove": "Retirer", + "common.create": "Créer", + "common.actions": "Actions", + "common.yes": "Oui", + "common.no": "Non", + "common.all": "Tous", + "common.none": "Aucun", + "common.search": "Rechercher", + "common.filter": "Filtrer", + "common.year": "Année", + "common.name": "Nom", + "common.email": "Courriel", + "common.status": "Statut", + "common.date": "Date", + "common.title": "Titre", + "common.description": "Description", + "common.networkError": "Erreur réseau", + "common.errorOccurred": "Une erreur est survenue", + "common.errorPrefix": "Erreur :", + "common.saving": "Enregistrement...", + "common.success": "Succès", + "common.toggleDarkMode": "Basculer le mode sombre", + "common.details": "Détails", + "common.oscarPool": "Pool Oscars", + + "auth.login": "Connexion", + "auth.register": "Enregistrer", + "auth.email": "Courriel", + "auth.password": "Mot de passe", + "auth.name": "Nom", + "auth.noAccount": "Pas de compte?", + "auth.hasAccount": "Déjà un compte?", + "auth.signUp": "Enregistrez-vous", + "auth.signIn": "Connectez-vous", + "auth.forgotPassword": "Mot de passe oublié?", + "auth.emailPlaceholder": "votre@courriel.com", + "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 + + + + + - - + -

Déjà un compte? Connectez-vous

+

Already have an account? Sign in

@@ -86,15 +102,15 @@ diff --git a/public/user-stat.html b/public/user-stat.html index ee916d6..3d4e541 100644 --- a/public/user-stat.html +++ b/public/user-stat.html @@ -3,11 +3,29 @@ - Pool Oscars - Statistiques des utilisateurs + Oscar Pool - Statistics + + +