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
+53 -3
View File
@@ -16,6 +16,7 @@ const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const VISIBILITY_CONFIG_KEY = "visibility_config";
const LANGUAGE_KEY = "site_language";
const crypto = require("crypto");
@@ -38,10 +39,14 @@ const DEFAULT_POINTS_CONFIG = Object.freeze({
});
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
showPicksButton: true,
showBonPicksColumn: true,
showPicksButton: false,
showBonPicksColumn: false,
showVersionTab: true,
});
const DEFAULT_LANGUAGE = "en";
const SUPPORTED_LANGUAGES = Object.freeze(["en", "fr"]);
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
@@ -772,7 +777,8 @@ function normalizeVisibilityConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
return { showPicksButton, showBonPicksColumn };
const showVersionTab = typeof v.showVersionTab === "boolean" ? v.showVersionTab : DEFAULT_VISIBILITY_CONFIG.showVersionTab;
return { showPicksButton, showBonPicksColumn, showVersionTab };
}
// Public: get points configuration
@@ -851,5 +857,49 @@ router.put("/visibility-config", async (req, res) => {
}
});
// Language normalization
function normalizeLanguage(raw) {
const lang = String(raw || "").trim().toLowerCase();
return SUPPORTED_LANGUAGES.includes(lang) ? lang : DEFAULT_LANGUAGE;
}
// Public: get current language setting
router.get("/language", async (req, res) => {
try {
const existing = await Setting.findOne({ key: LANGUAGE_KEY }).select("value");
const language = normalizeLanguage(existing?.value);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
return res.status(200).json({ language: DEFAULT_LANGUAGE, supported: SUPPORTED_LANGUAGES });
}
});
// Admin-only: set language
router.put("/language", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const language = normalizeLanguage(req.body?.language);
await Setting.findOneAndUpdate(
{ key: LANGUAGE_KEY },
{ $set: { value: language } },
{ upsert: true, new: true }
);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
module.exports = router;