Translate all out-of-game UI (start screen, lobbies, etc.) (#316)

This PR adds full translation support for all out-of-game UI elements,
including the start screen, public/private lobby modals, and other
pre-game interfaces.

All static text has been externalized to en.json and ja.json for future
language support.
If you find any spots that are not yet translated (missing from the
JSON), please let me know.
Thanks a lot!

This is a follow-up to PR
[#305](https://github.com/openfrontio/OpenFrontIO/pull/305).

---------

Co-authored-by: Cldprv <dubois.cnm@tutanota.com>
Co-authored-by: jacks0n <rosty.west89@gmail.com>
This commit is contained in:
Aotumuri
2025-03-25 09:12:04 +09:00
committed by GitHub
parent f188af6029
commit 9088adeb7a
14 changed files with 845 additions and 215 deletions
+156 -125
View File
@@ -11,6 +11,7 @@ import randomMap from "../../resources/images/RandomMap.png";
import { generateID } from "../core/Util";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "../client/Utils";
@customElement("host-lobby-modal")
export class HostLobbyModal extends LitElement {
@@ -37,7 +38,7 @@ export class HostLobbyModal extends LitElement {
render() {
return html`
<o-modal title="Private lobby">
<o-modal title=${translateText("host_modal.title")}>
<div class="lobby-id-box">
<button
class="lobby-id-button"
@@ -45,30 +46,32 @@ export class HostLobbyModal extends LitElement {
?disabled=${this.copySuccess}
>
<span class="lobby-id">${this.lobbyId}</span>
${this.copySuccess
? html`<span class="copy-success-icon">✓</span>`
: html`
<svg
class="clipboard-icon"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 512 512"
height="18px"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M296 48H176.5C154.4 48 136 65.4 136 87.5V96h-7.5C106.4 96 88 113.4 88 135.5v288c0 22.1 18.4 40.5 40.5 40.5h208c22.1 0 39.5-18.4 39.5-40.5V416h8.5c22.1 0 39.5-18.4 39.5-40.5V176L296 48zm0 44.6l83.4 83.4H296V92.6zm48 330.9c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5h7.5v255.5c0 22.1 10.4 32.5 32.5 32.5H344v7.5zm48-48c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5H264v128h128v167.5z"
></path>
</svg>
`}
${
this.copySuccess
? html`<span class="copy-success-icon">✓</span>`
: html`
<svg
class="clipboard-icon"
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 512 512"
height="18px"
width="18px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M296 48H176.5C154.4 48 136 65.4 136 87.5V96h-7.5C106.4 96 88 113.4 88 135.5v288c0 22.1 18.4 40.5 40.5 40.5h208c22.1 0 39.5-18.4 39.5-40.5V416h8.5c22.1 0 39.5-18.4 39.5-40.5V176L296 48zm0 44.6l83.4 83.4H296V92.6zm48 330.9c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5h7.5v255.5c0 22.1 10.4 32.5 32.5 32.5H344v7.5zm48-48c0 4.7-3.4 8.5-7.5 8.5h-208c-4.4 0-8.5-4.1-8.5-8.5v-288c0-4.1 3.8-7.5 8.5-7.5H264v128h128v167.5z"
></path>
</svg>
`
}
</button>
</div>
<div class="options-layout">
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">Map</div>
<div class="option-title">${translateText("host_modal.map")}</div>
<div class="option-cards">
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
@@ -79,14 +82,17 @@ export class HostLobbyModal extends LitElement {
.mapKey=${key}
.selected=${!this.useRandomMap &&
this.selectedMap === value}
.translation=${translateText(
`map.${key.toLowerCase()}`,
)}
></map-display>
</div>
`,
)}
<div
class="option-card random-map ${this.useRandomMap
? "selected"
: ""}"
class="option-card random-map ${
this.useRandomMap ? "selected" : ""
}"
@click=${this.handleRandomMapToggle}
>
<div class="option-image">
@@ -96,14 +102,14 @@ export class HostLobbyModal extends LitElement {
style="width:100%; aspect-ratio: 4/2; object-fit:cover; border-radius:8px;"
/>
</div>
<div class="option-card-title">Random</div>
<div class="option-card-title">${translateText("map.random")}</div>
</div>
</div>
</div>
<!-- Difficulty Selection -->
<div class="options-section">
<div class="option-title">Difficulty</div>
<div class="option-title">${translateText("host_modal.difficulty")}</div>
<div class="option-cards">
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
@@ -119,7 +125,9 @@ export class HostLobbyModal extends LitElement {
.difficultyKey=${key}
></difficulty-display>
<p class="option-card-title">
${DifficultyDescription[key]}
${translateText(
`difficulty.${DifficultyDescription[key]}`,
)}
</p>
</div>
`,
@@ -127,103 +135,125 @@ export class HostLobbyModal extends LitElement {
</div>
</div>
<!-- Game Options -->
<div class="options-section">
<div class="option-title">Options</div>
<div class="option-cards">
<label for="private-lobby-bots-count" class="option-card">
<input
type="range"
id="private-lobby-bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
</div>
</label>
<!-- Game Options -->
<div class="options-section">
<div class="option-title">
${translateText("host_modal.options_title")}
</div>
<div class="option-cards">
<label for="bots-count" class="option-card">
<input
type="range"
id="bots-count"
min="0"
max="400"
step="1"
@input=${this.handleBotsChange}
@change=${this.handleBotsChange}
.value="${String(this.bots)}"
/>
<div class="option-card-title">
<span>${translateText("host_modal.bots")}</span>${
this.bots == 0
? translateText("host_modal.bots_disabled")
: this.bots
}
</div>
</label>
<label
for="private-lobby-disable-npcd"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="private-lobby-disable-npcd"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">Disable Nations</div>
</label>
<label
for="disable-npcs"
class="option-card ${this.disableNPCs ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-npcs"
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nations")}
</div>
</label>
<label
for="private-lobby-instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="private-lobby-instant-build"
@change=${this.handleInstantBuildChange}
.checked=${this.instantBuild}
/>
<div class="option-card-title">Instant build</div>
</label>
<label
for="instant-build"
class="option-card ${this.instantBuild ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="instant-build"
@change=${this.handleInstantBuildChange}
.checked=${this.instantBuild}
/>
<div class="option-card-title">
${translateText("host_modal.instant_build")}
</div>
</label>
<label
for="private-lobby-infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="private-lobby-infinite-gold"
@change=${this.handleInfiniteGoldChange}
.checked=${this.infiniteGold}
/>
<div class="option-card-title">Infinite gold</div>
</label>
<label
for="infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="infinite-gold"
@change=${this.handleInfiniteGoldChange}
.checked=${this.infiniteGold}
/>
<div class="option-card-title">
${translateText("host_modal.infinite_gold")}
</div>
</label>
<label
for="private-lobby-infinite-troops"
class="option-card ${this.infiniteTroops ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="private-lobby-infinite-troops"
@change=${this.handleInfiniteTroopsChange}
.checked=${this.infiniteTroops}
/>
<div class="option-card-title">Infinite troops</div>
</label>
<label
for="private-lobby-disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">Disable Nukes</div>
</label>
<label
for="infinite-troops"
class="option-card ${this.infiniteTroops ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="infinite-troops"
@change=${this.handleInfiniteTroopsChange}
.checked=${this.infiniteTroops}
/>
<div class="option-card-title">
${translateText("host_modal.infinite_troops")}
</div>
</label>
<label
for="disable-nukes"
class="option-card ${this.disableNukes ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="disable-nukes"
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">
${translateText("host_modal.disable_nukes")}
</div>
</label>
</div>
</div>
</div>
<!-- Lobby Selection -->
<div class="options-section">
<div class="option-title">
${this.players.length}
${this.players.length === 1 ? "Player" : "Players"}
</div>
<!-- Lobby Selection -->
<div class="options-section">
<div class="option-title">
${this.players.length}
${
this.players.length === 1
? translateText("host_modal.player")
: translateText("host_modal.players")
}
</div>
<div class="players-list">
${this.players.map(
@@ -231,17 +261,18 @@ export class HostLobbyModal extends LitElement {
)}
</div>
</div>
</div>
<div class="flex justify-center">
<o-button
.title=${this.players.length === 1
? "Waiting for players..."
: "Start Game"}
?disable=${this.players.length < 2}
<button
@click=${this.startGame}
block
?disabled=${this.players.length < 2}
class="start-game-button"
>
</o-button>
${
this.players.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")
}
</button>
</div>
</o-modal>
`;
+11 -8
View File
@@ -4,6 +4,7 @@ import { consolex } from "../core/Consolex";
import { GameInfo, GameRecord } from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "../client/Utils";
import "./components/baseComponents/Modal";
import "./components/baseComponents/Button";
@@ -22,12 +23,12 @@ export class JoinPrivateLobbyModal extends LitElement {
render() {
return html`
<o-modal title="Join Private Lobby">
<o-modal title=${translateText("private_lobby.title")}>
<div class="lobby-id-box">
<input
type="text"
id="lobbyIdInput"
placeholder="Enter Lobby ID"
placeholder=${translateText("private_lobby.enter_id")}
@keyup=${this.handleChange}
/>
<button
@@ -58,7 +59,9 @@ export class JoinPrivateLobbyModal extends LitElement {
? html` <div class="options-section">
<div class="option-title">
${this.players.length}
${this.players.length === 1 ? "Player" : "Players"}
${this.players.length === 1
? translateText("private_lobby.player")
: translateText("private_lobby.players")}
</div>
<div class="players-list">
@@ -72,7 +75,7 @@ export class JoinPrivateLobbyModal extends LitElement {
<div class="flex justify-center">
${!this.hasJoined
? html` <o-button
title="Join Lobby"
title=${translateText("private_lobby.join_lobby")}
block
@click=${this.joinLobby}
></o-button>`
@@ -149,7 +152,7 @@ export class JoinPrivateLobbyModal extends LitElement {
private async joinLobby(): Promise<void> {
const lobbyId = this.lobbyIdInput.value;
consolex.log(`Joining lobby with ID: ${lobbyId}`);
this.message = "Checking lobby...";
this.message = `${translateText("private_lobby.checking")}`;
try {
// First, check if the game exists in active lobbies
@@ -160,10 +163,10 @@ export class JoinPrivateLobbyModal extends LitElement {
const archivedGame = await this.checkArchivedGame(lobbyId);
if (archivedGame) return;
this.message = "Lobby not found. Please check the ID and try again.";
this.message = `${translateText("private_lobby.not_found")}`;
} catch (error) {
consolex.error("Error checking lobby existence:", error);
this.message = "An error occurred. Please try again.";
this.message = `${translateText("private_lobby.error")}`;
}
}
@@ -179,7 +182,7 @@ export class JoinPrivateLobbyModal extends LitElement {
const gameInfo = await response.json();
if (gameInfo.exists) {
this.message = "Joined successfully! Waiting for game to start...";
this.message = translateText("private_lobby.joined_waiting");
this.hasJoined = true;
this.dispatchEvent(
+10 -4
View File
@@ -4,6 +4,7 @@ import { Difficulty, GameMapType, GameType } from "../core/game/Game";
import { consolex } from "../core/Consolex";
import { getMapsImage } from "./utilities/Maps";
import { GameID, GameInfo } from "../core/Schemas";
import { translateText } from "../client/Utils";
@customElement("public-lobby")
export class PublicLobby extends LitElement {
@@ -102,7 +103,9 @@ export class PublicLobby extends LitElement {
? "opacity-70 cursor-not-allowed"
: ""}"
>
<div class="text-lg md:text-2xl font-semibold mb-2">Join next Game</div>
<div class="text-lg md:text-2xl font-semibold mb-2">
${translateText("public_lobby.join")}
</div>
<div class="flex">
<img
src="${getMapsImage(lobby.gameConfig.gameMap)}"
@@ -115,13 +118,16 @@ export class PublicLobby extends LitElement {
>
<div class="flex flex-col items-start">
<div class="text-md font-medium text-blue-100">
${lobby.gameConfig.gameMap}
<!-- ${lobby.gameConfig.gameMap} -->
${translateText(
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/\s+/g, "")}`,
)}
</div>
</div>
<div class="flex flex-col items-start">
<div class="text-md font-medium text-blue-100">
${lobby.numClients} / ${lobby.gameConfig.maxPlayers} players
waiting
${lobby.numClients} / ${lobby.gameConfig.maxPlayers}
${translateText("public_lobby.waiting")}
</div>
</div>
<div class="flex items-center">
+38 -13
View File
@@ -11,6 +11,7 @@ import "./components/Maps";
import randomMap from "../../resources/images/RandomMap.png";
import { GameInfo } from "../core/Schemas";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "../client/Utils";
@customElement("single-player-modal")
export class SinglePlayerModal extends LitElement {
@@ -30,11 +31,11 @@ export class SinglePlayerModal extends LitElement {
render() {
return html`
<o-modal title="Single Player">
<o-modal title=${translateText("single_modal.title")}>
<div class="options-layout">
<!-- Map Selection -->
<div class="options-section">
<div class="option-title">Map</div>
<div class="option-title">${translateText("single_modal.map")}</div>
<div class="option-cards">
${Object.entries(GameMapType)
.filter(([key]) => isNaN(Number(key)))
@@ -49,6 +50,9 @@ export class SinglePlayerModal extends LitElement {
.mapKey=${key}
.selected=${!this.useRandomMap &&
this.selectedMap === value}
.translation=${translateText(
`map.${key.toLowerCase()}`,
)}
></map-display>
</div>
`,
@@ -66,14 +70,18 @@ export class SinglePlayerModal extends LitElement {
style="width:100%; aspect-ratio: 4/2; object-fit:cover; border-radius:8px;"
/>
</div>
<div class="option-card-title">Random</div>
<div class="option-card-title">
${translateText("map.random")}
</div>
</div>
</div>
</div>
<!-- Difficulty Selection -->
<div class="options-section">
<div class="option-title">Difficulty</div>
<div class="option-title">
${translateText("single_modal.difficulty")}
</div>
<div class="option-cards">
${Object.entries(Difficulty)
.filter(([key]) => isNaN(Number(key)))
@@ -89,7 +97,9 @@ export class SinglePlayerModal extends LitElement {
.difficultyKey=${key}
></difficulty-display>
<p class="option-card-title">
${DifficultyDescription[key]}
${translateText(
`difficulty.${DifficultyDescription[key]}`,
)}
</p>
</div>
`,
@@ -99,7 +109,9 @@ export class SinglePlayerModal extends LitElement {
<!-- Game Options -->
<div class="options-section">
<div class="option-title">Options</div>
<div class="option-title">
${translateText("single_modal.options_title")}
</div>
<div class="option-cards">
<label for="bots-count" class="option-card">
<input
@@ -113,7 +125,10 @@ export class SinglePlayerModal extends LitElement {
.value="${this.bots}"
/>
<div class="option-card-title">
Bots: ${this.bots == 0 ? "Disabled" : this.bots}
<span>${translateText("single_modal.bots")}</span>${this
.bots == 0
? translateText("single_modal.bots_disabled")
: this.bots}
</div>
</label>
@@ -128,7 +143,9 @@ export class SinglePlayerModal extends LitElement {
@change=${this.handleDisableNPCsChange}
.checked=${this.disableNPCs}
/>
<div class="option-card-title">Disable Nations</div>
<div class="option-card-title">
${translateText("single_modal.disable_nations")}
</div>
</label>
<label
for="instant-build"
@@ -141,7 +158,9 @@ export class SinglePlayerModal extends LitElement {
@change=${this.handleInstantBuildChange}
.checked=${this.instantBuild}
/>
<div class="option-card-title">Instant build</div>
<div class="option-card-title">
${translateText("single_modal.instant_build")}
</div>
</label>
<label
@@ -155,7 +174,9 @@ export class SinglePlayerModal extends LitElement {
@change=${this.handleInfiniteGoldChange}
.checked=${this.infiniteGold}
/>
<div class="option-card-title">Infinite gold</div>
<div class="option-card-title">
${translateText("single_modal.infinite_gold")}
</div>
</label>
<label
@@ -169,7 +190,9 @@ export class SinglePlayerModal extends LitElement {
@change=${this.handleInfiniteTroopsChange}
.checked=${this.infiniteTroops}
/>
<div class="option-card-title">Infinite troops</div>
<div class="option-card-title">
${translateText("single_modal.infinite_troops")}
</div>
</label>
<label
@@ -183,14 +206,16 @@ export class SinglePlayerModal extends LitElement {
@change=${this.handleDisableNukesChange}
.checked=${this.disableNukes}
/>
<div class="option-card-title">Disable Nukes</div>
<div class="option-card-title">
${translateText("single_modal.disable_nukes")}
</div>
</label>
</div>
</div>
</div>
<o-button
title="Start Game"
title=${translateText("single_modal.start")}
@click=${this.startGame}
blockDesktop
></o-button>
+2 -1
View File
@@ -6,6 +6,7 @@ import {
validateUsername,
} from "../core/validations/username";
import { UserSettings } from "../core/game/UserSettings";
import { translateText } from "../client/Utils";
const usernameKey: string = "username";
@@ -40,7 +41,7 @@ export class UsernameInput extends LitElement {
.value=${this.username}
@input=${this.handleChange}
@change=${this.handleChange}
placeholder="Enter your username"
placeholder="${translateText("username.enter_username")}"
maxlength="${MAX_USERNAME_LENGTH}"
class="w-full px-4 py-2 border border-gray-300 rounded-xl shadow-sm text-2xl text-center focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:border-gray-300/60 dark:bg-gray-700 dark:text-white"
/>
+27
View File
@@ -70,3 +70,30 @@ export function generateCryptoRandomUUID(): string {
},
);
}
export function translateText(
key: string,
params: Record<string, string | number> = {},
): string {
const keys = key.split(".");
let text: any = (window as any).translations;
for (const k of keys) {
text = text?.[k];
if (!text) break;
}
if (!text && (window as any).defaultTranslations) {
text = (window as any).defaultTranslations;
for (const k of keys) {
text = text?.[k];
if (!text) return key;
}
}
for (const [param, value] of Object.entries(params)) {
text = text.replace(`{${param}}`, String(value));
}
return text;
}
+4 -1
View File
@@ -25,6 +25,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
export class MapDisplay extends LitElement {
@property({ type: String }) mapKey = "";
@property({ type: Boolean }) selected = false;
@property({ type: String }) translation: string = "";
static styles = css`
.option-card {
@@ -90,7 +91,9 @@ export class MapDisplay extends LitElement {
<p>${this.mapKey}</p>
</div>`}
<div class="option-card-title">
${MapDescription[this.mapKey as keyof typeof GameMapType]}
<!-- ${MapDescription[this.mapKey as keyof typeof GameMapType]}-->
${this.translation ||
MapDescription[this.mapKey as keyof typeof GameMapType]}
</div>
</div>
`;
+95 -1
View File
@@ -304,7 +304,10 @@
class="text-center appearance-none w-full bg-blue-100 hover:bg-blue-200 text-blue-900 p-3 sm:p-4 lg:p-5 font-medium text-sm sm:text-base lg:text-lg rounded-md border-none cursor-pointer transition-colors duration-300"
>
<option value="en">English</option>
<option value="bg">Български</option>
<option value="ja">日本語</option>
<option value="fr">Français</option>
<option value="nl">Nederlands</option>
</select>
</div>
</div>
@@ -394,7 +397,98 @@
});
});
</script>
<script src="lang.js"></script>
<script>
document.addEventListener("DOMContentLoaded", async function () {
const locale = new Intl.Locale(navigator.language);
const defaultLang = locale.language;
const userLang = localStorage.getItem("lang") || defaultLang;
async function loadLanguage(lang) {
try {
const response = await fetch(`/lang/${lang}.json`);
if (!response.ok)
throw new Error(`Language file not found: ${lang}`);
return await response.json();
} catch (error) {
console.error("🚨 Translation load error:", error);
return {};
}
}
function applyTranslation(translations) {
const components = [
"single-player-modal",
"host-lobby-modal",
"join-private-lobby-modal",
"emoji-table",
"leader-board",
"build-menu",
"win-modal",
"game-starting-modal",
"top-bar",
"player-panel",
"help-modal",
"username-input",
"public-lobby",
];
document.title = translations.main?.title || document.title;
document.querySelectorAll("[data-i18n]").forEach((element) => {
const key = element.getAttribute("data-i18n");
const keys = key.split(".");
let text = translations;
for (const k of keys) {
text = text?.[k];
if (!text) break;
}
if (!text && window.defaultTranslations) {
let fallback = window.defaultTranslations;
for (const k of keys) {
fallback = fallback?.[k];
if (!fallback) break;
}
text = fallback;
}
if (text) {
element.innerHTML = text;
} else {
console.warn(`Missing translation key: ${key}`);
}
});
components.forEach((tagName) => {
const el = document.querySelector(tagName);
if (el && typeof el.requestUpdate === "function") {
el.requestUpdate();
} else {
console.warn(
`requestUpdate() not available on <${tagName}> or element not found.`,
);
}
});
}
async function changeLanguage(lang) {
// console.log(`Changing language to: ${lang}`);
localStorage.setItem("lang", lang);
const translations = await loadLanguage(lang);
window.translations = translations;
applyTranslation(translations);
}
const defaultTranslations = await loadLanguage("en");
window.defaultTranslations = defaultTranslations;
const translations = await loadLanguage(userLang);
window.translations = translations;
applyTranslation(translations);
const langSelector = document.getElementById("lang-selector");
if (langSelector) {
langSelector.value = userLang;
}
document
.getElementById("lang-selector")
.addEventListener("change", function (event) {
changeLanguage(event.target.value);
});
});
</script>
<!-- Analytics -->
<script
-57
View File
@@ -1,57 +0,0 @@
document.addEventListener("DOMContentLoaded", async function () {
const defaultLang = navigator.language.startsWith("ja") ? "ja" : "en";
const userLang = localStorage.getItem("lang") || defaultLang;
async function loadLanguage(lang) {
try {
const response = await fetch(`/lang/${lang}.json`);
if (!response.ok) throw new Error(`Language file not found: ${lang}`);
return await response.json();
} catch (error) {
console.error("🚨 Translation load error:", error);
return {};
}
}
function applyTranslation(translations) {
document.title = translations.main?.title || document.title;
document.querySelectorAll("[data-i18n]").forEach((element) => {
const key = element.getAttribute("data-i18n");
const keys = key.split(".");
let text = translations;
for (const k of keys) {
text = text?.[k];
if (!text) break;
}
if (text) {
element.innerHTML = text;
} else {
console.warn(`Missing translation key: ${key}`);
}
});
}
async function changeLanguage(lang) {
// console.log(`Changing language to: ${lang}`);
localStorage.setItem("lang", lang);
const translations = await loadLanguage(lang);
applyTranslation(translations);
}
const translations = await loadLanguage(userLang);
applyTranslation(translations);
const langSelector = document.getElementById("lang-selector");
if (langSelector) {
langSelector.value = userLang;
}
document
.getElementById("lang-selector")
.addEventListener("change", function (event) {
changeLanguage(event.target.value);
});
});
+11 -5
View File
@@ -4,6 +4,7 @@ import {
englishRecommendedTransformers,
} from "obscenity";
import { simpleHash } from "../Util";
import { translateText } from "../../client/Utils";
const matcher = new RegExpMatcher({
...englishDataset.build(),
@@ -41,28 +42,33 @@ export function validateUsername(username: string): {
error?: string;
} {
if (typeof username !== "string") {
return { isValid: false, error: "Username must be a string." };
return { isValid: false, error: translateText("username.not_string") };
}
if (username.length < MIN_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters long.`,
error: translateText("username.too_short", {
min: MIN_USERNAME_LENGTH,
}),
};
}
if (username.length > MAX_USERNAME_LENGTH) {
return {
isValid: false,
error: `Username must not exceed ${MAX_USERNAME_LENGTH} characters.`,
error: translateText("username.too_long", {
max: MAX_USERNAME_LENGTH,
}),
};
}
if (!validPattern.test(username)) {
return {
isValid: false,
error:
"Username can only contain letters, numbers, spaces, underscores, and [square brackets].",
error: translateText("username.invalid_chars", {
max: MAX_USERNAME_LENGTH,
}),
};
}