Implement en/fr localization across admin and user UIs

Introduce client-side localization helper and en/fr locale bundles.

Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text.

Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations).

Update .gitignore to ignore editor metadata and localization helper artifacts.
This commit is contained in:
Mathieu Moran
2026-02-01 20:20:02 -05:00
parent dd313f8f5e
commit 4308d6babf
31 changed files with 3063 additions and 811 deletions
+17 -3
View File
@@ -2,6 +2,20 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function isTokenExpired(decoded) {
const currentTime = Math.floor(Date.now() / 1000);
return !!decoded.exp && decoded.exp < currentTime;
@@ -81,11 +95,11 @@ window.onload = function () {
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword.length < 8) {
showError('Le mot de passe doit contenir au moins 8 caractères.');
showError(t('auth.passwordMinLength', 'Password must be at least 8 characters.'));
return;
}
if (newPassword !== confirmPassword) {
showError('Les mots de passe ne correspondent pas.');
showError(t('auth.passwordsDoNotMatch', 'Passwords do not match'));
return;
}
@@ -116,7 +130,7 @@ window.onload = function () {
window.location.href = '/movies.html';
} catch (err) {
showError(err.message || 'Erreur réseau');
showError(err.message || t('auth.networkError', 'Network error'));
}
});
};
+78 -21
View File
@@ -1,7 +1,21 @@
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -119,8 +133,8 @@ function createPageLoader(options = {}) {
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement de la checklist',
subtitle: 'Récupération des données…'
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.loadingChecklist') : 'Loading checklist',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
const token = localStorage.getItem('auth_token');
@@ -133,7 +147,7 @@ window.addEventListener('DOMContentLoaded', async function () {
<tr>
<td colspan="3">
<div class="alert alert-danger my-2" role="alert">
${message || 'Erreur lors du chargement.'}
${message || t('common.errorLoading', 'Error loading.')}
</div>
</td>
</tr>
@@ -141,7 +155,7 @@ window.addEventListener('DOMContentLoaded', async function () {
}
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉',
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.congratulations') : 'Congratulations! very nice 🎉🎉🎉',
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: ''
@@ -265,16 +279,20 @@ window.addEventListener('DOMContentLoaded', async function () {
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
const currentDate = new Date();
let timeDifference = fallbackDate - currentDate;
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
@@ -283,13 +301,17 @@ window.addEventListener('DOMContentLoaded', async function () {
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
const now = new Date();
timeDifference = targetDate - now;
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
if (token) {
@@ -333,7 +355,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalCount = Number(summary?.totalMoviesCount) || 0;
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
if (watchedRatioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedCount, total: totalCount, percent: ratioPct })
: `Watched: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
watchedRatioEl.innerText = txt;
}
updateProgressBar(watchedCount, totalCount, false);
pageLoader.setProgress(32);
return summary;
@@ -341,7 +368,7 @@ window.addEventListener('DOMContentLoaded', async function () {
.catch(() => null);
// Fetch remaining data in parallel.
pageLoader.setSubtitle('Chargement de la liste des films…');
pageLoader.setSubtitle(t('checklist.loadingMovies', 'Loading movies…'));
pageLoader.setProgress(26);
const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(),
@@ -372,8 +399,8 @@ window.addEventListener('DOMContentLoaded', async function () {
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
} catch (e) {
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.');
showTableError((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoad') : 'Unable to load checklist. Please try again later.');
pageLoader.fail((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoadNetwork') : 'Unable to load checklist. Check your connection and try again.');
return;
}
@@ -383,7 +410,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
if (document.getElementById('watched-ratio')) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = txt;
}
function launchConfetti() {
const duration = 3 * 1000; // 3 seconds
@@ -522,12 +554,22 @@ window.addEventListener('DOMContentLoaded', async function () {
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount);
const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
}
});
@@ -562,20 +604,35 @@ window.addEventListener('DOMContentLoaded', async function () {
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount);
// Recalculate and update movies per day
const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
}
});
});
const moviesLeft = totalMoviesCount - watchedMoviesCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(92);
+310 -164
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -86,10 +86,12 @@
// Show button immediately based on cached value
if (cachedValue.showPicksButton !== false) {
picksButton.style.display = '';
} else {
picksButton.style.display = 'none';
}
} else {
// Default to showing while we fetch
picksButton.style.display = '';
// Default to hiding while we fetch (new installs should start disabled)
picksButton.style.display = 'none';
}
// Fetch fresh value from server
+38 -4
View File
@@ -2,6 +2,39 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
"L'utilisateur n'a pas été trouvé": 'auth.userNotFound',
'User not found': 'auth.userNotFound',
"Informations d'identification non valides": 'auth.invalidCredentials',
'Invalid credentials': 'auth.invalidCredentials',
"Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.": 'auth.tempPasswordExpired',
'Temporary password expired. Ask an admin to reset your password.': 'auth.tempPasswordExpired',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
}
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
@@ -110,13 +143,13 @@ if (setupForm) {
window.location.href = '/movies.html';
} else {
if (setupError) {
setupError.textContent = data.message || 'Erreur lors de la création du compte';
setupError.textContent = data.message || t('auth.errorCreatingAccount', 'Error creating account');
setupError.classList.remove('d-none');
}
}
} catch (err) {
if (setupError) {
setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
setupError.textContent = t('auth.networkError', 'Network error. Please try again.');
setupError.classList.remove('d-none');
}
}
@@ -147,7 +180,8 @@ document.getElementById('login-form').addEventListener('submit', async function
window.location.href = '/movies.html'; // Redirect to movies page on success
}
} else {
const error = await res.json();
alert(`${error.message}`);
const error = await res.json().catch(() => ({}));
const msg = translateApiMessage(error.messageKey, error.message, 'auth.loginFailed', 'Login failed');
alert(msg);
}
});
+77 -25
View File
@@ -1,7 +1,21 @@
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -147,8 +161,8 @@ async function waitForImagesIn(containerEl, onProgress) {
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des films',
subtitle: 'Récupération des données…'
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.loadingMovies') : 'Loading movies',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
function formatClock(seconds) {
@@ -272,7 +286,11 @@ window.addEventListener('DOMContentLoaded', async function () {
if (avgEl) {
if (avg && count > 0) {
avgEl.textContent = `Note utilisateurs ${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const avgLabel = `${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const translated = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: avgLabel })
: `User rating ${avgLabel}`;
avgEl.textContent = translated;
avgEl.classList.remove('movie-rating-sub--empty');
} else {
avgEl.textContent = '';
@@ -335,7 +353,12 @@ window.addEventListener('DOMContentLoaded', async function () {
if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
if (!Number.isNaN(d.getTime())) {
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
try {
lastUpdatedEl.textContent = d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
lastUpdatedEl.textContent = d.toLocaleString();
}
} else {
lastUpdatedEl.textContent = '—';
}
@@ -347,7 +370,12 @@ window.addEventListener('DOMContentLoaded', async function () {
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
if (ratioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watched, total: total, percent: ratioPct })
: `Watched: ${watched} / ${total} (${ratioPct}%)`;
ratioEl.innerText = txt;
}
}
const activeYear = await fetchActiveYear();
@@ -361,16 +389,18 @@ window.addEventListener('DOMContentLoaded', async function () {
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
@@ -380,14 +410,16 @@ window.addEventListener('DOMContentLoaded', async function () {
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
@@ -473,8 +505,11 @@ window.addEventListener('DOMContentLoaded', async function () {
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = ratioText;
const ratioText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.innerText = ratioText;
if (moviesList) moviesList.innerHTML = '';
@@ -501,6 +536,11 @@ window.addEventListener('DOMContentLoaded', async function () {
Number.isFinite(userRatingAvg) && userRatingAvg > 0
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
: '';
const avgHtml = userRatingText
? ((window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: userRatingText })
: `User rating ${userRatingText}`)
: '';
const userRatingOwn =
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
const starsHtml = Array.from({ length: 5 }, (_, idx) => {
@@ -531,10 +571,13 @@ window.addEventListener('DOMContentLoaded', async function () {
// Hide "resume/progress" once we're basically at the credits.
// Backend uses 95% as the "credit roll" threshold too.
if (pct < 95) {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center text-muted small">
<span>Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span></span>
<span>${resumeText}</span>
<span>${pct.toFixed(0)}%</span>
</div>
<div class="progress" style="height: 6px;">
@@ -544,9 +587,12 @@ window.addEventListener('DOMContentLoaded', async function () {
`;
}
} else {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2 text-muted small">
Reprendre à <span class="fw-semibold text-dark">${formatClock(time)}</span>
${resumeText}
</div>
`;
}
@@ -554,6 +600,12 @@ window.addEventListener('DOMContentLoaded', async function () {
}
const camBadge = movie?.cam ? '<span class="badge bg-warning text-dark position-absolute top-0 end-0 m-2 cam-badge">CAM</span>' : '';
const watchedBadge = isChecked
? `<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.watched') : 'WATCHED'}</span>`
: '';
const noSourceHtml = !isClickable
? `<div class="text-muted small mb-1">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.noSource') : 'No source'}</div>`
: '';
const posterBlock = `
<div class="text-center pt-3 px-3 position-relative movie-poster-wrap">
${camBadge}
@@ -566,7 +618,7 @@ window.addEventListener('DOMContentLoaded', async function () {
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = `
<div class="card h-100 shadow-sm">
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
${watchedBadge}
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
@@ -587,26 +639,26 @@ window.addEventListener('DOMContentLoaded', async function () {
</h5>
${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
<div class="movie-rating-block mt-1">
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
<div class="movie-rating-sub movie-rating-avg${avgHtml ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${avgHtml ? avgHtml : ''}</div>
</div>
</div>
<div class="movie-imdb-wrap">
<div class="movie-imdb-block">
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'}">
<span class="imdb-icon" aria-hidden="true">IMDb</span>
</a>
</div>
<div class="movie-rating-stars" role="group" aria-label="Noter ce film" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
<div class="movie-rating-stars" role="group" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('player.rateThisFilm') : 'Rate this film'}" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
${starsHtml}
</div>
</div>
</div>
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</div>' : ''}
${noSourceHtml}
<div class="movie-info-box">
<details class="movie-info-categories">
<summary class="movie-info-summary">
<span class="movie-info-summary-title">Catégories nominées</span>
<span class="movie-info-summary-title">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.nominatedCategories') : 'Nominated categories'}</span>
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
</summary>
<div class="movie-info-category">${movie.category}</div>
@@ -615,7 +667,7 @@ window.addEventListener('DOMContentLoaded', async function () {
</div>
</div>
<div class="mt-auto">
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.watchedOn') : 'Watched on:'}</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div>
@@ -658,8 +710,8 @@ window.addEventListener('DOMContentLoaded', async function () {
});
pageLoader.done();
} catch (e) {
setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.');
setLoadError(moviesList, t('movies.errorLoadingList', 'Unable to load movie list. Try again in a moment.'));
pageLoader.fail(t('movies.errorLoading', 'Unable to load movies. Check your connection and try again.'));
}
document.getElementById('log-off').addEventListener('click', function () {
+74 -49
View File
@@ -2,9 +2,9 @@
'use strict';
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -120,6 +120,21 @@
return { setProgress, setSubtitle, done, fail };
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
@@ -198,7 +213,7 @@
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes picks`;
document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
}
if (pageLoader) pageLoader.setProgress(18);
@@ -225,10 +240,12 @@
document.getElementById('current-year').textContent = currentYear;
if (categories.length === 0) {
const noCats = t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear });
const adminHint = t('picks.adminMustImportCategories', 'An administrator must first import the categories.');
document.getElementById('categories-container').innerHTML = `
<div class="alert alert-warning">
Aucune catégorie trouvée pour l'année ${currentYear}.
Un administrateur doit d'abord importer les catégories.
${noCats}
${adminHint}
</div>
`;
if (pageLoader) pageLoader.done();
@@ -243,7 +260,7 @@
if (pageLoader) pageLoader.setProgress(85);
} catch (err) {
console.error('Error fetching categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
if (pageLoader) pageLoader.fail();
}
}
@@ -287,7 +304,7 @@
if (!container) return;
if (categories.length === 0) {
container.innerHTML = '<div class="text-center py-4 text-muted">Aucune catégorie disponible</div>';
container.innerHTML = `<div class="text-center py-4 text-muted">${t('picks.noCategoriesAvailable', 'No categories available')}</div>`;
return;
}
@@ -297,9 +314,9 @@
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Choix</th>
<th>Nommés</th>
<th>${t('picks.category', 'Category')}</th>
<th style="width: 200px;">${t('picks.choice', 'Choice')}</th>
<th>${t('picks.nominees', 'Nominees')}</th>
</tr>
</thead>
<tbody>
@@ -314,7 +331,7 @@
<span class="selected-choice-badge badge bg-success">
<span>${selectedNominee}</span>
</span>
` : '<span class="text-muted fst-italic">Aucun choix</span>'}
` : `<span class="text-muted fst-italic">${t('picks.noChoice', 'No choice')}</span>`}
</td>
<td>
<div class="d-flex flex-wrap gap-2">
@@ -386,7 +403,7 @@
} catch (err) {
console.error('Auto-save failed:', err);
// Show error alert only on failure
showAlert('Erreur lors de l\'enregistrement automatique: ' + err.message, 'danger');
showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger');
} finally {
isSaving = false;
}
@@ -413,7 +430,7 @@
// Skip confirmation dialog in silent mode (auto-save)
if (!silent && picks.length < categories.length) {
if (!confirm(`Vous n'avez sélectionné que ${picks.length} sur ${categories.length} catégories. Voulez-vous enregistrer quand même?`)) {
if (!confirm(t('picks.incompleteSelection', `You have selected only ${picks.length} out of ${categories.length} categories. Do you want to save anyway?`, { selected: picks.length, total: categories.length }))) {
return;
}
}
@@ -438,18 +455,18 @@
const data = await res.json();
if (!silent) {
showAlert('Vos choix ont été enregistrés avec succès!', 'success');
showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success');
}
} catch (err) {
console.error('Error submitting picks:', err);
showAlert('Erreur lors de l\'enregistrement: ' + err.message, 'danger');
showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger');
}
}
// Event listeners
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert('Choix rechargés', 'info');
showAlert(t('picks.reloaded', 'Picks reloaded'), 'info');
});
// Winners & Scores functionality (admin only)
@@ -480,7 +497,7 @@
await fetchScores();
} catch (err) {
console.error('Error fetching winners categories:', err);
showAlert('Erreur lors du chargement des catégories: ' + err.message, 'danger');
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
}
}
@@ -489,7 +506,7 @@
if (!container) return;
if (winnersCategories.length === 0) {
container.innerHTML = '<div class="alert alert-warning">Aucune catégorie trouvée pour cette année.</div>';
container.innerHTML = `<div class="alert alert-warning">${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}</div>`;
return;
}
@@ -506,9 +523,9 @@
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>Catégorie</th>
<th style="width: 200px;">Sélection</th>
<th>Gagnant</th>
<th>${t('picks.categoryHeader', 'Category')}</th>
<th style="width: 200px;">${t('picks.selectionHeader', 'Selection')}</th>
<th>${t('picks.winnerHeader', 'Winner')}</th>
</tr>
</thead>
<tbody>
@@ -559,13 +576,18 @@
throw new Error(error.message || 'Failed to update winner');
}
showAlert(winnerName ? 'Gagnant marqué avec succès' : 'Gagnant supprimé', 'success');
showAlert(
winnerName
? t('picks.winnerMarked', 'Winner marked successfully')
: t('picks.winnerDeleted', 'Winner deleted'),
'success'
);
await fetchWinnersCategories();
// Auto-refresh scores after marking/clearing a winner (silent mode)
await calculateScores(true);
} catch (err) {
console.error('Error updating winner:', err);
showAlert('Erreur: ' + err.message, 'danger');
showAlert(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger');
}
});
});
@@ -580,7 +602,7 @@
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = true;
btn.textContent = 'Calcul en cours...';
btn.textContent = t('picks.calculatingScores', 'Calculating scores…');
}
const res = await fetch('/api/picks/calculate-scores', {
@@ -598,7 +620,7 @@
if (error.message && error.message.includes('No winners marked')) {
if (!silent) {
// Only show as info/warning, not error
showAlert('Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.', 'info');
showAlert(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info');
}
// Still try to fetch existing scores
await fetchScores();
@@ -609,19 +631,22 @@
const data = await res.json();
if (!silent) {
showAlert(`Scores calculés avec succès! ${data.scores.length} utilisateur(s) évalué(s).`, 'success');
showAlert(
t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }),
'success'
);
}
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
if (!silent) {
showAlert('Erreur: ' + err.message, 'danger');
showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger');
}
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = false;
btn.textContent = 'Calculer les scores';
btn.textContent = t('picks.calculateScores', 'Calculate scores');
}
}
}
@@ -653,7 +678,7 @@
console.error('Error fetching scores:', err);
const container = document.getElementById('scores-container');
if (container) {
container.innerHTML = '<div class="text-danger">Erreur lors du chargement des scores.</div>';
container.innerHTML = `<div class="text-danger">${t('picks.errorLoadingScores', 'Error loading scores.')}</div>`;
}
}
}
@@ -684,7 +709,7 @@
}).sort((a, b) => b.actualScore - a.actualScore);
if (validScores.length === 0) {
container.innerHTML = '<div class="text-muted">Aucun score disponible. Les utilisateurs doivent d\'abord soumettre leurs choix.</div>';
container.innerHTML = `<div class="text-muted">${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}</div>`;
return;
}
@@ -694,7 +719,7 @@
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>Utilisateur</th>
<th>${t('picks.userHeader', 'User')}</th>
<th class="text-center" style="width: 80px;">Score</th>
<th class="text-center" style="width: 70px;">Total</th>
<th class="text-center" style="width: 100px;">Actions</th>
@@ -714,7 +739,7 @@
<td class="text-center text-muted small">${totalCategories}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
Détails
${t('picks.detailsButton', 'Details')}
</button>
</td>
</tr>
@@ -743,10 +768,10 @@
const modalTitle = document.getElementById('userPicksModalLabel');
const modalContent = document.getElementById('user-picks-modal-content');
modalTitle.textContent = `Choix de ${score.userName}`;
modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName });
if (!score.pickDetails || score.pickDetails.length === 0) {
modalContent.innerHTML = '<div class="text-muted">Aucun détail disponible.</div>';
modalContent.innerHTML = `<div class="text-muted">${t('picks.noDetailsAvailable', 'No details available.')}</div>`;
modal.show();
return;
}
@@ -763,11 +788,11 @@
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-1">Résumé</h6>
<h6 class="mb-1">${t('picks.summaryTitle', 'Summary')}</h6>
<div class="text-muted small">
<span class="text-success"><strong>${correctPicks.length}</strong> correct(s)</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> incorrect(s)</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> total</span>
<span class="text-success"><strong>${correctPicks.length}</strong> ${t('picks.correctCountLabel', 'correct')}</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> ${t('picks.incorrectCountLabel', 'incorrect')}</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> ${t('picks.totalCountLabel', 'total')}</span>
</div>
</div>
<div class="text-end">
@@ -781,7 +806,7 @@
<div class="mb-4">
<h6 class="text-success mb-3">
<span class="badge bg-success me-2">✓</span>
Corrects (${correctPicks.length})
${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
</h6>
<div class="list-group">
${correctPicks.map(pick => `
@@ -804,7 +829,7 @@
<div class="mb-4">
<h6 class="text-danger mb-3">
<span class="badge bg-danger me-2">✗</span>
Incorrects (${incorrectPicks.length})
${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
</h6>
<div class="list-group">
${incorrectPicks.map(pick => `
@@ -814,13 +839,13 @@
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-2">
<div class="mb-1">
<span class="badge bg-danger">✗ Choix: ${pick.selectedNominee}</span>
<span class="badge bg-danger">✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}</span>
</div>
${pick.correctWinner ? `
<div>
<span class="badge bg-success">✓ Gagnant: ${pick.correctWinner}</span>
<span class="badge bg-success">✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}</span>
</div>
` : '<div class="text-muted small">Aucun gagnant marqué</div>'}
` : `<div class="text-muted small">${t('picks.noWinnerMarkedShort', 'No winner marked')}</div>`}
</div>
</div>
</div>
@@ -832,7 +857,7 @@
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
<div class="alert alert-info">
Aucun choix à afficher.
${t('picks.noChoicesToShow', 'No picks to show.')}
</div>
` : ''}
`;
@@ -843,7 +868,7 @@
// Event listeners for winners tab
document.getElementById('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert('Données rafraîchies', 'info');
showAlert(t('picks.dataRefreshed', 'Data refreshed'), 'info');
});
// Load scores when scores tab is shown
@@ -867,19 +892,19 @@
// Refresh scores button
document.getElementById('refresh-scores')?.addEventListener('click', async () => {
await fetchScores();
showAlert('Scores rafraîchis', 'info');
showAlert(t('picks.scoresRefreshed', 'Scores refreshed'), 'info');
});
// Initial load
window.addEventListener('DOMContentLoaded', async function() {
const pageLoader = createPageLoader({
title: 'Chargement des picks',
subtitle: 'Récupération des données…'
title: t('picks.loadingTitle', 'Loading picks'),
subtitle: t('picks.loadingSubtitle', 'Fetching data…')
});
try {
currentYear = await fetchActiveYear();
document.title = `Pool Oscars (${currentYear}) - Mes choix`;
document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
pageLoader.setProgress(12);
await fetchCategories(pageLoader);
+32 -18
View File
@@ -7,6 +7,20 @@ function decodeJwt(token) {
return JSON.parse(atob(padded));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
let progress = 0;
@@ -262,19 +276,19 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
pendingRestoreTime = clamped;
restoreInProgress = true;
try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
lastSaveStatus = t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) });
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: t('player.resumingAt', 'Resuming at {time}', { time: formatClock(clamped) }) });
} else {
pendingRestoreTime = null;
restoreInProgress = false;
lastSaveStatus = 'Début';
setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' });
lastSaveStatus = t('player.beginning', 'Start');
setProgressUi({ state: 'info', text: t('player.beginning', 'Start'), showText: true, ariaText: t('player.playFromBeginning', 'Playing from the beginning') });
}
return true;
}
function scheduleRestore() {
if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
if (!restored) setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
void tryRestoreProgress();
}
@@ -315,18 +329,18 @@ function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
} catch (_) {}
// Icon-only saving indicator (avoid noisy text updates).
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' });
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: t('player.saving', 'Saving…') });
const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, keepalive: !!keepalive });
if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user.
stopInterval();
try { localStorage.removeItem('auth_token'); } catch (_) {}
lastSaveStatus = 'Connexion requise pour sauvegarder.';
setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus });
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
lastSaveStatus = t('player.connectionRequired', 'Connection required for saving.');
setProgressUi({ state: 'warning', text: t('player.connection', 'Connection'), showText: true, ariaText: lastSaveStatus });
showAlertVariant(t('player.sessionExpired', 'Session expired. Please reconnect to continue saving progress.'), 'warning');
return;
}
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
lastSaveStatus = t('player.saved', 'Saved at {time}.', { time: formatClock(snap.time) });
setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus });
if (savedUiTimer) window.clearTimeout(savedUiTimer);
savedUiTimer = window.setTimeout(() => {
@@ -574,11 +588,11 @@ function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount,
});
const parts = [];
if (avg && count > 0) {
parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`);
} else {
parts.push('Note utilisateurs: —');
}
const ratingLabel = (avg && count > 0) ? `${avg.toFixed(1)} (${count})` : '—';
const translatedRating = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: ratingLabel })
: `User rating ${ratingLabel}`;
parts.push(translatedRating);
metaEl.textContent = parts.join(' • ');
}
@@ -1250,7 +1264,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) {
}
window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
const pageLoader = createPageLoader({ title: t('player.loadingPlayer', 'Loading player…') });
// Basic auth UX: require a valid local token (like the rest of the app).
const token = localStorage.getItem('auth_token');
@@ -1373,13 +1387,13 @@ window.onload = async function () {
const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) {
applySubtitleTrack({ videoEl, movie, token });
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
setProgressUi({ state: 'preparing', text: t('player.resuming', 'Resuming'), showText: true, ariaText: t('player.preparingResume', 'Preparing resume…') });
const userId = normalizeUserId(decoded);
if (userId) {
setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id });
} else {
// Avoid a "stuck" progress state if the token payload is unexpected.
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." });
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: t('player.unableToIdentifyUser', "Unable to identify user for saving progress.") });
}
} else {
setProgressUi({ state: 'info', text: '—', showText: true });
+27 -2
View File
@@ -2,6 +2,22 @@ function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
'Mauvais nom de chien': 'auth.dogAnswerIncorrect',
"L'utilisateur s'est enregistré avec succès !": 'auth.registrationSuccess',
'User registered successfully!': 'auth.registrationSuccess',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
}
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
@@ -92,7 +108,12 @@ initPage();
if (res.ok) {
const data = await res.json();
// Show success message in the modal
responseMessageElement.textContent = data.message;
responseMessageElement.textContent = translateApiMessage(
data?.messageKey,
data?.message,
'auth.registrationSuccess',
'User registered successfully!'
);
// Show the modal
modal.show();
@@ -104,7 +125,11 @@ initPage();
} else {
const error = await res.json();
// Show error message in the modal
responseMessageElement.textContent = `Error: ${error.message}`;
const prefix = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('common.errorPrefix')
: 'Error:';
const msg = translateApiMessage(error?.messageKey, error?.message, 'common.errorOccurred', 'An error occurred');
responseMessageElement.textContent = `${prefix} ${msg}`.trim();
// Show the modal
modal.show();
+23 -3
View File
@@ -7,6 +7,21 @@
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const API_ENDPOINT = '/api/users/theme';
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function normalizeTheme(value) {
return value === 'light' || value === 'dark' ? value : null;
}
@@ -111,9 +126,14 @@
buttons.forEach((button) => {
button.setAttribute('aria-checked', String(isDark));
button.dataset.theme = theme;
const label = isDark ? 'Mode sombre' : 'Mode clair';
const sourceLabel = source === 'system' ? ' (système)' : '';
button.setAttribute('title', `${label}${sourceLabel} — Shift+clic pour système`);
const label = isDark
? t('theme.darkMode', 'Dark mode')
: t('theme.lightMode', 'Light mode');
const sourceLabel = source === 'system'
? t('theme.systemSuffix', ' (system)')
: '';
const hint = t('theme.systemHint', '— Shift+click for system');
button.setAttribute('title', `${label}${sourceLabel} ${hint}`.trim());
});
}
+50 -26
View File
@@ -1,7 +1,7 @@
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
const subtitle = String(options.subtitle || t('stats.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
@@ -118,10 +118,31 @@ function createPageLoader(options = {}) {
return { setProgress, setSubtitle, done, fail };
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des statistiques',
subtitle: 'Récupération des données…'
title: t('stats.loadingStats', 'Loading statistics'),
subtitle: t('stats.fetchingData', 'Fetching data…')
});
const token = localStorage.getItem('auth_token');
@@ -173,7 +194,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!list.length) {
tbody.innerHTML = `
<tr>
<td colspan="2" class="text-muted">Aucun gagnant défini.</td>
<td colspan="2" class="text-muted">${t('stats.noWinnersDefined', 'No winners defined.')}</td>
</tr>
`;
return;
@@ -187,7 +208,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const arr = byYear.get(String(y)) || [];
arr.push({
year: y,
name: w?.name || '(utilisateur supprimé)',
name: w?.name || t('stats.deletedUser', '(deleted user)'),
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
});
byYear.set(String(y), arr);
@@ -215,7 +236,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (sorted.length > 1) {
const small = document.createElement('div');
small.className = 'text-muted small';
small.textContent = 'égalité';
small.textContent = t('stats.tie', 'tie');
tdYear.appendChild(document.createElement('br'));
tdYear.appendChild(small);
}
@@ -227,7 +248,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const name = document.createElement('span');
name.className = 'fw-semibold';
name.textContent = w?.name || '(utilisateur supprimé)';
name.textContent = w?.name || t('stats.deletedUser', '(deleted user)');
row.appendChild(name);
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
@@ -254,7 +275,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!completions || typeof completions !== 'object') {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">Impossible de charger les finisseurs 100%.</td>
<td colspan="3" class="text-muted">${t('stats.unableToLoadCompleters', 'Unable to load 100% completers.')}</td>
</tr>
`;
return;
@@ -267,7 +288,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (!years.length) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">Aucune année trouvée.</td>
<td colspan="3" class="text-muted">${t('stats.noYearsFound', 'No years found.')}</td>
</tr>
`;
return;
@@ -311,9 +332,9 @@ window.addEventListener('DOMContentLoaded', async function () {
let dateStr = '';
if (u?.completedAt) {
const date = new Date(u.completedAt);
dateStr = ` (${date.toLocaleDateString('fr-CA')})`;
dateStr = ` (${date.toLocaleDateString(getLocale())})`;
}
badge.textContent = `${rankPrefix}${u?.name || '(sans nom)'}${dateStr}`;
badge.textContent = `${rankPrefix}${u?.name || t('stats.unnamed', '(unnamed)')}${dateStr}`;
wrap.appendChild(badge);
});
tdNames.appendChild(wrap);
@@ -359,7 +380,7 @@ window.addEventListener('DOMContentLoaded', async function () {
try {
pageLoader.setProgress(12);
// Modal UI (Films regardés)
// Modal UI (watched movies)
const moviesModalEl = document.getElementById('moviesModal');
const movieGridEl = document.getElementById('movie-grid');
const moviesSummaryEl = document.getElementById('movies-summary');
@@ -383,7 +404,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
try {
return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' });
return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', day: 'numeric' });
} catch (_) {
return d.toLocaleDateString();
}
@@ -413,7 +434,9 @@ window.addEventListener('DOMContentLoaded', async function () {
const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : []));
const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0;
moviesSummaryEl.textContent = total ? `${total} film${total > 1 ? 's' : ''}` : '0 film';
moviesSummaryEl.textContent = total
? t('stats.filmCount', '{count} movie{plural}', { count: total, plural: total > 1 ? 's' : '' })
: t('stats.noFilms', '0 movies');
movieGridEl.innerHTML = '';
if (!sorted.length) {
@@ -452,7 +475,7 @@ window.addEventListener('DOMContentLoaded', async function () {
item.classList.add('stats-movie-item--clickable');
item.setAttribute('role', 'link');
item.setAttribute('tabindex', '0');
item.setAttribute('aria-label', vodLink ? `Ouvrir le lien: ${title}` : `Ouvrir le lecteur: ${title}`);
item.setAttribute('aria-label', vodLink ? t('stats.openLink', 'Open link') + ': ' + title : t('stats.openPlayer', 'Open player') + ': ' + title);
}
const poster = document.createElement('img');
@@ -523,7 +546,7 @@ window.addEventListener('DOMContentLoaded', async function () {
imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`;
imdbLink.target = '_blank';
imdbLink.rel = 'noopener noreferrer';
imdbLink.setAttribute('aria-label', 'Voir sur IMDb');
imdbLink.setAttribute('aria-label', (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb');
const imdbIcon = document.createElement('span');
imdbIcon.className = 'imdb-icon';
@@ -555,7 +578,7 @@ window.addEventListener('DOMContentLoaded', async function () {
if (watchedLabel) {
const d = document.createElement('span');
d.className = 'text-muted';
d.textContent = `Vu le ${watchedLabel}`;
d.textContent = t('stats.watchedOn', 'Watched on {date}', { date: watchedLabel });
meta.appendChild(d);
}
@@ -568,7 +591,7 @@ window.addEventListener('DOMContentLoaded', async function () {
details.className = 'stats-movie-details';
const summary = document.createElement('summary');
summary.textContent = 'Détails';
summary.textContent = t('common.details', 'Details');
details.appendChild(summary);
const body = document.createElement('div');
@@ -592,8 +615,8 @@ window.addEventListener('DOMContentLoaded', async function () {
if (moviesModalTitleEl) {
const count = currentModalMovies.length;
moviesModalTitleEl.textContent = currentModalUserName
? `Films regardés — ${currentModalUserName} (${count})`
: `Films regardés (${count})`;
? t('stats.watchedMoviesUser', 'Watched movies — {user} ({count})', { user: currentModalUserName, count: count })
: t('stats.watchedMovies', 'Watched movies ({count})', { count: count });
}
renderMoviesModal();
@@ -619,10 +642,10 @@ window.addEventListener('DOMContentLoaded', async function () {
pageLoader.setProgress(20);
const activeYear = await fetchActiveYear();
if (activeYear) {
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
document.title = `Pool Oscars (${activeYear}) - ${t('stats.pageTitle', 'User Statistics')}`;
// Keep the main heading clean; show the year in the "Classement" tab instead.
const h2 = document.querySelector('h2');
if (h2) h2.textContent = 'Statistiques des utilisateurs';
if (h2) h2.textContent = t('stats.pageTitle', 'User Statistics');
const badge = document.getElementById('stats-active-year-badge');
if (badge) {
badge.textContent = String(activeYear);
@@ -656,7 +679,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const userTableBody = document.getElementById('user-table-body');
// Check visibility config for Bon picks column
let showBonPicksColumn = true;
let showBonPicksColumn = false;
try {
const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (visibilityRes.ok) {
@@ -694,7 +717,8 @@ window.addEventListener('DOMContentLoaded', async function () {
// Update Films column header with total
const filmsHeader = document.getElementById('films-header');
if (filmsHeader && totalMovies > 0) {
filmsHeader.innerHTML = `Films <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
const filmsLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('stats.films') : 'Movies';
filmsHeader.innerHTML = `${filmsLabel} <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
}
stats.forEach((userStat, index) => {
@@ -751,7 +775,7 @@ window.addEventListener('DOMContentLoaded', async function () {
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) {
statsError.textContent = "Impossible de charger le classement pour le moment.";
statsError.textContent = t('stats.unableToLoadRanking', 'Unable to load the ranking right now.');
statsError.classList.remove('d-none');
}
// Still attempt to render these sections even if stats fail (best effort)
+13 -1
View File
@@ -3,8 +3,20 @@
const res = await fetch('/api/version');
if (res.ok) {
const data = await res.json();
if (window.i18n && typeof window.i18n.init === 'function') {
try {
await window.i18n.init();
} catch (_) {}
}
const hasI18n = window.i18n && typeof window.i18n.t === 'function';
let message = data.message || '';
if (data && data.configured === false) {
if (hasI18n) {
message = window.i18n.t('footer.versionNotConfigured');
}
}
// Update footer with the fetched version info
const footerText = `${data.date} - ${data.version} - ${data.message}`;
const footerText = `${data.date} - ${data.version} - ${message}`;
document.getElementById('footer-text').textContent = footerText;
} else {
document.getElementById('footer-text').textContent = '';
+40 -9
View File
@@ -41,6 +41,27 @@
let categories = [];
let currentYear = new Date().getFullYear();
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
@@ -91,7 +112,7 @@
renderPicks();
} catch (err) {
console.error('Error fetching picks:', err);
showAlert('Erreur lors du chargement des choix: ' + err.message, 'danger');
showAlert(t('viewPicks.errorLoading', 'Error loading picks: {error}', { error: err.message }), 'danger');
}
}
@@ -102,7 +123,7 @@
if (allPicks.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
Aucun utilisateur n'a encore soumis ses choix pour l'année ${currentYear}.
${t('viewPicks.noPicks', 'No user has submitted picks for year {year} yet.', { year: currentYear })}
</div>
`;
return;
@@ -115,9 +136,19 @@
});
container.innerHTML = allPicks.map(pick => {
const userName = pick.userId?.name || 'Utilisateur inconnu';
const userName = pick.userId?.name || t('viewPicks.unknownUser', 'Unknown user');
const userEmail = pick.userId?.email || '';
const submittedDate = pick.submittedAt ? new Date(pick.submittedAt).toLocaleString('fr-FR') : '—';
let submittedDate = '—';
if (pick.submittedAt) {
const d = new Date(pick.submittedAt);
if (!Number.isNaN(d.getTime())) {
try {
submittedDate = d.toLocaleString(getLocale(), { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
submittedDate = d.toLocaleString();
}
}
}
// Create a map of picks by category
const picksMap = new Map();
@@ -141,7 +172,7 @@
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
<span class="text-muted">${selected}</span>
</div>
${isWinner ? '<span class="badge bg-success">Gagnant</span>' : ''}
${isWinner ? `<span class="badge bg-success">${t('viewPicks.winnerBadge', 'Winner')}</span>` : ''}
</div>
</div>
`;
@@ -156,13 +187,13 @@
<small class="text-muted">${userEmail}</small>
</div>
<div class="text-end">
<small class="text-muted">Soumis le: ${submittedDate}</small><br>
<span class="badge bg-primary">${pick.picks.length} / ${categories.length} catégories</span>
<small class="text-muted">${t('viewPicks.submittedOn', 'Submitted on: {date}', { date: submittedDate })}</small><br>
<span class="badge bg-primary">${t('viewPicks.categoriesCount', '{count} / {total} categories', { count: pick.picks.length, total: categories.length })}</span>
</div>
</div>
</div>
<div class="card-body">
${picksList || '<div class="text-muted">Aucun choix enregistré</div>'}
${picksList || `<div class="text-muted">${t('viewPicks.noPicks2', 'No picks recorded')}</div>`}
</div>
</div>
`;
@@ -172,7 +203,7 @@
// Event listeners
document.getElementById('refresh-picks')?.addEventListener('click', () => {
fetchAllPicks();
showAlert('Choix rafraîchis', 'info');
showAlert(t('viewPicks.picksRefreshed', 'Picks refreshed'), 'info');
});
// Initial load
+306
View File
@@ -0,0 +1,306 @@
/**
* Lightweight i18n module for Oscar Pool
* Supports English (base) and French translations
*/
(function() {
'use strict';
const STORAGE_KEY = 'app_language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'fr'];
let currentLang = DEFAULT_LANG;
let translations = {};
let loadedLangs = new Set();
let initPromise = null;
/**
* Detect language from: localStorage → browser → default
*/
function detectLanguage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) {
return stored;
}
const browserLang = (navigator.language || navigator.userLanguage || '').split('-')[0].toLowerCase();
if (SUPPORTED_LANGS.includes(browserLang)) {
return browserLang;
}
return DEFAULT_LANG;
}
/**
* Load translation file for a language
*/
async function loadTranslations(lang) {
if (loadedLangs.has(lang)) return true;
try {
const res = await fetch(`/locales/${lang}.json`, { cache: 'default' });
if (!res.ok) {
console.warn(`i18n: Failed to load ${lang}.json (${res.status})`);
return false;
}
const data = await res.json();
translations[lang] = data;
loadedLangs.add(lang);
return true;
} catch (err) {
console.warn(`i18n: Error loading ${lang}.json`, err);
return false;
}
}
/**
* Translate a key with optional interpolation
* @param {string} key - Translation key (e.g., 'nav.logout')
* @param {Object} params - Optional parameters for interpolation
* @returns {string} - Translated string or key if not found
*/
function t(key, params = {}) {
if (!key) return '';
// Try current language, then fallback to default
let text = translations[currentLang]?.[key];
if (text === undefined && currentLang !== DEFAULT_LANG) {
text = translations[DEFAULT_LANG]?.[key];
}
if (text === undefined) {
// Return key as fallback (helps identify missing translations)
return key;
}
// Simple interpolation: {varName} → value
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
const regex = new RegExp(`\\{${k}\\}`, 'g');
text = text.replace(regex, String(params[k]));
});
}
return text;
}
/**
* Set the current language
*/
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
return true;
}
/**
* Get the current language
*/
function getLanguage() {
return currentLang;
}
/**
* Get list of supported languages
*/
function getSupportedLanguages() {
return [...SUPPORTED_LANGS];
}
/**
* Translate all elements with data-i18n attributes
*/
function translatePage() {
// Translate text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
// Check for interpolation params
const paramsAttr = el.getAttribute('data-i18n-params');
let params = {};
if (paramsAttr) {
try {
params = JSON.parse(paramsAttr);
} catch (_) {
// Ignore invalid JSON
}
}
const translated = t(key, params);
if (translated !== key) {
el.textContent = translated;
}
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.placeholder = translated;
}
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.setAttribute('aria-label', translated);
}
});
// Translate titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.title = translated;
}
});
// Translate option values in selects
document.querySelectorAll('option[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.textContent = translated;
}
});
// Mark body as ready (for CSS transitions)
document.body.classList.add('i18n-ready');
}
/**
* Update a single element's translation (for dynamic content)
*/
function translateElement(el, key, params = {}) {
if (!el || !key) return;
el.textContent = t(key, params);
}
/**
* Fetch language setting from server (optional, for admin-set language)
*/
async function fetchServerLanguage() {
try {
const res = await fetch('/api/settings/language', {
method: 'GET',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.language && SUPPORTED_LANGS.includes(data.language)) {
return data.language;
}
}
} catch (_) {
// Silently fail - use local detection
}
return null;
}
/**
* Initialize i18n system
* @param {string} forceLang - Optional language to force (bypasses detection)
*/
async function init(forceLang = null) {
// Prevent multiple concurrent initializations
if (initPromise) return initPromise;
initPromise = (async () => {
// Determine language to use
let lang = forceLang;
if (!lang) {
// Try server setting first (if user is logged in and admin set a language)
const serverLang = await fetchServerLanguage();
if (serverLang) {
lang = serverLang;
} else {
lang = detectLanguage();
}
}
// Always load default language as fallback
await loadTranslations(DEFAULT_LANG);
// Load current language if different
if (lang !== DEFAULT_LANG) {
const loaded = await loadTranslations(lang);
if (!loaded) {
lang = DEFAULT_LANG;
}
}
setLanguage(lang);
translatePage();
return lang;
})();
return initPromise;
}
/**
* Change language and re-translate page
*/
async function changeLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
// Load translations if not already loaded
if (!loadedLangs.has(lang)) {
const loaded = await loadTranslations(lang);
if (!loaded) return false;
}
setLanguage(lang);
translatePage();
return true;
}
/**
* Check if translations are loaded
*/
function isReady() {
return loadedLangs.size > 0;
}
// Expose globally
window.i18n = {
t,
init,
setLanguage,
getLanguage,
changeLanguage,
getSupportedLanguages,
translatePage,
translateElement,
loadTranslations,
fetchServerLanguage,
isReady,
DEFAULT_LANG,
SUPPORTED_LANGS
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
// DOM already loaded
init();
}
})();