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 + + +