feat: Integrate ngx-translate for multilingual support and add English and French translations

This commit is contained in:
nessar committed 2025-11-10 01:39:22 +01:00
1 parent 36c98db93f
commit 9f8167fc58
14 files changed
+265 -34

No files matched your search

+4 -2
View File
@@ -19,7 +19,8 @@
{
"glob": "**/*",
"input": "public"
}
},
"src/assets"
],
"styles": ["src/custom-theme.scss", "src/styles.css"],
"server": "src/main.server.ts",
@@ -85,7 +86,8 @@
{
"glob": "**/*",
"input": "public"
}
},
"src/assets"
],
"styles": ["src/styles.css"]
}
+29
View File
@@ -24,6 +24,8 @@
"@fortawesome/free-brands-svg-icons": "^7.0.0",
"@fortawesome/free-regular-svg-icons": "^7.0.0",
"@fortawesome/free-solid-svg-icons": "^7.0.0",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
@@ -2804,6 +2806,33 @@
"node": ">= 10"
}
},
"node_modules/@ngx-translate/core": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-14.0.0.tgz",
"integrity": "sha512-UevdwNCXMRCdJv//0kC8h2eSfmi02r29xeE8E9gJ1Al4D4jEJ7eiLPdjslTMc21oJNGguqqWeEVjf64SFtvw2w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/core": ">=13.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@ngx-translate/http-loader": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-7.0.0.tgz",
"integrity": "sha512-j+NpXXlcGVdyUNyY/qsJrqqeAdJdizCd+GKh3usXExSqy1aE9866jlAIL+xrfDU4w+LiMoma5pgE4emvFebZmA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=13.0.0",
"@ngx-translate/core": ">=14.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+3 -1
View File
@@ -46,7 +46,9 @@
"http-proxy-middleware": "^3.0.5",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"typescript-eslint": "^8.46.3"
"typescript-eslint": "^8.46.3",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0"
},
"devDependencies": {
"@angular/build": "^20.3.7",
+16
View File
@@ -3,6 +3,7 @@ import {
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
isDevMode,
importProvidersFrom,
} from '@angular/core';
import { provideRouter } from '@angular/router';
@@ -11,6 +12,9 @@ import { provideClientHydration, withEventReplay } from '@angular/platform-brows
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
import { imageCacheInterceptor } from './interceptors/image-cache.interceptor';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
@@ -23,5 +27,17 @@ export const appConfig: ApplicationConfig = {
enabled: !isDevMode(),
registrationStrategy: 'registerImmediately',
}),
// Provide ngx-translate through importProvidersFrom so it is available
// for standalone component templates via the translate pipe.
importProvidersFrom(
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: (http: HttpClient) =>
new TranslateHttpLoader(http, '/assets/i18n/', '.json'),
deps: [HttpClient],
},
}),
),
],
};
+2 -2
View File
@@ -1,11 +1,11 @@
<mat-toolbar color="primary" class="hero-toolbar" role="banner">
<mat-icon aria-hidden="true">casino</mat-icon>
<span class="title">Random League Squad</span>
<span class="title">{{ 'app.title' | translate }}</span>
</mat-toolbar>
<section class="app" aria-labelledby="app-intro">
<p id="app-intro" class="subtitle mat-body-large">
Roll a unique champion for every lane in one click.
{{ 'app.subtitle' | translate }}
</p>
<mat-card class="randomizer-card">
<mat-card-content>
+29 -1
View File
@@ -4,10 +4,17 @@ import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { ChampionRandomizerComponent } from './components/champion-randomizer.component';
import { ImagePreloadService } from './services/image-preload.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-root',
imports: [MatToolbarModule, MatIconModule, MatCardModule, ChampionRandomizerComponent],
imports: [
MatToolbarModule,
MatIconModule,
MatCardModule,
ChampionRandomizerComponent,
TranslateModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrls: ['./app.css'],
@@ -15,4 +22,25 @@ import { ImagePreloadService } from './services/image-preload.service';
export class App {
// Initialize the image preload service
private readonly imagePreload = inject(ImagePreloadService);
private readonly translate = inject(TranslateService);
constructor() {
// Initialize language: prefer saved value, otherwise try the user's
// browser language (navigator.languages / navigator.language), fall back to English.
this.translate.setDefaultLang('en');
let lang: string | null = null;
try {
lang = typeof localStorage !== 'undefined' ? localStorage.getItem('lang') : null;
} catch {}
if (!lang && typeof navigator !== 'undefined') {
const candidate =
(navigator.languages && navigator.languages[0]) || navigator.language || '';
if (candidate.startsWith('fr')) {
lang = 'fr';
} else {
lang = 'en';
}
}
this.translate.use(lang || 'en');
}
}
@@ -10,7 +10,7 @@
(click)="onRoll()"
>
<mat-icon aria-hidden="true">casino</mat-icon>
<span>Roll Champions</span>
<span>{{ 'control.roll' | translate }}</span>
</button>
<button
type="button"
@@ -21,7 +21,7 @@
(click)="onCopyDraft()"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
<span>Copy Draft</span>
<span>{{ 'control.copy' | translate }}</span>
</button>
@if (state.fearlessDraftEnabled()) {
<button
@@ -33,7 +33,7 @@
(click)="onReset()"
>
<mat-icon aria-hidden="true">restart_alt</mat-icon>
<span>Reset Blacklist</span>
<span>{{ 'control.reset' | translate }}</span>
</button>
}
<button
@@ -44,13 +44,14 @@
(click)="openSettings()"
>
<mat-icon aria-hidden="true">settings</mat-icon>
<span>Settings</span>
<span>{{ 'control.settings' | translate }}</span>
</button>
</div>
<div class="hint mat-body-small">
<mat-icon aria-hidden="true" class="iconFlex">info</mat-icon>
<span style="height: 24px; display: flex; align-items: center">
Each roll keeps champions unique across every lane.
{{ 'control.hint' | translate }}
</span>
</div>
</div>
@@ -61,7 +62,7 @@
@if (state.isLoading()) {
<div class="status mat-body-small" aria-live="polite">
<mat-icon aria-hidden="true">autorenew</mat-icon>
<span>Loading champion data&hellip;</span>
<span>{{ 'control.loading' | translate }}</span>
</div>
} @else if (state.loadError(); as errorMessage) {
<div class="status error mat-body-small" aria-live="polite">
@@ -69,7 +70,7 @@
<span>{{ errorMessage }}</span>
<button type="button" mat-button color="accent" (click)="onRetry()">
<mat-icon aria-hidden="true">refresh</mat-icon>
<span>Retry</span>
<span>{{ 'control.retry' | translate }}</span>
</button>
</div>
}
@@ -5,10 +5,11 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { SettingsDialogComponent } from '../settings-dialog/settings-dialog.component';
import { RandomizerStateService } from '../../services/randomizer-state.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@Component({
selector: 'app-control-header',
imports: [MatButtonModule, MatIconModule, MatProgressSpinnerModule],
imports: [MatButtonModule, MatIconModule, MatProgressSpinnerModule, TranslateModule],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './control-header.component.html',
styleUrl: './control-header.component.css',
@@ -16,6 +17,19 @@ import { RandomizerStateService } from '../../services/randomizer-state.service'
export class ControlHeaderComponent {
private readonly dialog = inject(MatDialog);
protected readonly state = inject(RandomizerStateService);
private readonly translate = inject(TranslateService);
// Expose current language for UI or binding
get currentLang(): string {
return this.translate.currentLang || this.translate.getDefaultLang() || 'en';
}
setLang(lang: string): void {
this.translate.use(lang);
try {
localStorage.setItem('lang', lang);
} catch {}
}
onRoll(): void {
this.state.rollAssignments();
@@ -43,6 +57,8 @@ export class ControlHeaderComponent {
// Update the service state immediately
this.state.setFearlessDraftEnabled(value);
},
currentLang: this.currentLang,
setLang: (lang: string) => this.setLang(lang),
},
});
}
@@ -3,16 +3,14 @@
<mat-card-header class="mat-header">
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>Lane currently disabled</mat-card-subtitle>
<mat-card-subtitle>{{ 'lane.disabled_subtitle' | translate }}</mat-card-subtitle>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<div class="disabled-message">
<mat-icon class="disabled-icon" aria-hidden="true">block</mat-icon>
<p class="mat-body-large">This lane has been disabled</p>
<p class="mat-body-small">
You chose to exclude this lane from the random selection.
</p>
<p class="mat-body-large">{{ 'lane.disabled_title' | translate }}</p>
<p class="mat-body-small">{{ 'lane.disabled_detail' | translate }}</p>
</div>
<button
type="button"
@@ -23,7 +21,7 @@
(click)="onToggleDisableLane()"
>
<mat-icon aria-hidden="true">add_circle</mat-icon>
Enable Lane
{{ 'lane.enable' | translate }}
</button>
</mat-card-content>
</mat-card>
@@ -33,7 +31,7 @@
<mat-card-title-group>
<mat-card-title>{{ laneLabel() }}</mat-card-title>
<mat-card-subtitle>
{{ champion() ? 'Unique pick locked in' : 'Champion needed for this lane' }}
{{ champion() ? ('lane.locked' | translate) : ('lane.needed' | translate) }}
</mat-card-subtitle>
</mat-card-title-group>
<button
@@ -65,10 +63,10 @@
</mat-chip-set>
} @else {
<p class="champion-name champion-name-empty mat-title-medium">
No champion available
{{ 'lane.no_champion' | translate }}
</p>
<p class="champion-roles mat-body-small">
Roll again once champions finish loading.
{{ 'lane.roll_again_hint' | translate }}
</p>
}
@if (canReRoll()) {
@@ -82,7 +80,7 @@
(click)="onChangeChampion(false)"
>
<mat-icon aria-hidden="true">refresh</mat-icon>
Re-Roll !
{{ 'lane.reroll' | translate }}
</button>
} @else {
<button
@@ -105,9 +103,9 @@
<mat-icon aria-hidden="true">lock_open_right</mat-icon>
}
@if (!state.isShiftPressed()) {
<p>No re-rolls left</p>
<p>{{ 'lane.no_rerolls_left' | translate }}</p>
} @else {
<p>Force Re-Roll!</p>
<p>{{ 'lane.force_reroll' | translate }}</p>
}
</button>
}
@@ -4,12 +4,20 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { Lane } from '../../data/champions.data';
import { RandomizerStateService } from '../../services/randomizer-state.service';
@Component({
selector: 'app-lane-card',
imports: [NgOptimizedImage, MatButtonModule, MatCardModule, MatChipsModule, MatIconModule],
imports: [
NgOptimizedImage,
MatButtonModule,
MatCardModule,
MatChipsModule,
MatIconModule,
TranslateModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './lane-card.component.html',
styleUrl: './lane-card.component.css',
@@ -1,7 +1,7 @@
<h2 mat-dialog-title>Settings</h2>
<h2 mat-dialog-title>{{ 'settings.title' | translate }}</h2>
<mat-dialog-content>
<div class="setting-item">
<label for="fearless-draft-toggle">Fearless Draft</label>
<label for="fearless-draft-toggle">{{ 'settings.fearless_draft' | translate }}</label>
<!-- Bind directly to the injected data via the change handler so the
parent is updated immediately when the user toggles the switch. -->
<mat-slide-toggle
@@ -10,10 +10,23 @@
color="primary"
(change)="onToggle($event.checked)"
></mat-slide-toggle>
<p class="setting-description">
When enabled, champions selected in one draft are blacklisted from future rerolls until
reset.
</p>
<p class="setting-description">{{ 'settings.fearless_draft_desc' | translate }}</p>
</div>
<div class="setting-item">
<label for="language-select">{{ 'settings.language' | translate }}</label>
<mat-form-field appearance="outline" style="width: 100%; margin-top: 8px">
<mat-select
id="language-select"
[value]="currentLang"
(selectionChange)="onLangChange($event.value)"
>
@for (lang of languages; track lang) {
<mat-option [value]="lang">{{
'settings.languages.' + lang | translate
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-dialog-content>
@@ -1,6 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
export interface SettingsData {
fearlessDraftEnabled: boolean;
@@ -10,11 +13,21 @@ export interface SettingsData {
export interface SettingsDialogData {
fearlessDraftEnabled: boolean;
setFearless: (value: boolean) => void;
// currentLang is the current active language (e.g. 'en' or 'fr')
currentLang?: string;
// setLang callback to change the language at runtime
setLang?: (lang: string) => void;
}
@Component({
selector: 'app-settings-dialog',
imports: [MatDialogModule, MatSlideToggleModule],
imports: [
MatDialogModule,
MatSlideToggleModule,
MatFormFieldModule,
MatSelectModule,
TranslateModule,
],
templateUrl: './settings-dialog.component.html',
styleUrl: './settings-dialog.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -23,6 +36,10 @@ export class SettingsDialogComponent {
// Inject the dialog data which contains the current value and a setter callback.
private data = inject(MAT_DIALOG_DATA) as SettingsDialogData;
private dialogRef = inject(MatDialogRef<SettingsDialogComponent>);
private translate = inject(TranslateService);
// Supported languages; keep in sync with assets/i18n
protected readonly languages = ['en', 'fr'];
// Called when the toggle changes — immediately call the provided setter so
// the parent receives the change right away (no Save/Cancel required).
@@ -32,8 +49,31 @@ export class SettingsDialogComponent {
this.data.fearlessDraftEnabled = checked;
}
onLangChange(lang: string): void {
// If the parent provided a setter, call it; otherwise change directly
if (this.data.setLang) {
this.data.setLang(lang);
} else {
this.translate.use(lang);
try {
localStorage.setItem('lang', lang);
} catch {}
}
// update local copy
this.data.currentLang = lang;
}
// Small helper used by the template to read the current value.
get fearlessDraftEnabled(): boolean {
return this.data.fearlessDraftEnabled;
}
get currentLang(): string {
return (
this.data.currentLang ||
this.translate.currentLang ||
this.translate.getDefaultLang() ||
'en'
);
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"app": {
"title": "Random League Squad",
"subtitle": "Roll a unique champion for every lane in one click."
},
"control": {
"roll": "Roll Champions",
"copy": "Copy Draft",
"reset": "Reset Blacklist",
"settings": "Settings",
"hint": "Each roll keeps champions unique across every lane.",
"loading": "Loading champion data…",
"retry": "Retry"
},
"lane": {
"disabled_subtitle": "Lane currently disabled",
"disabled_title": "This lane has been disabled",
"disabled_detail": "You chose to exclude this lane from the random selection.",
"enable": "Enable Lane",
"locked": "Unique pick locked in",
"needed": "Champion needed for this lane",
"no_champion": "No champion available",
"roll_again_hint": "Roll again once champions finish loading.",
"reroll": "Re-Roll !",
"no_rerolls_left": "No re-rolls left",
"force_reroll": "Force Re-Roll!",
"roles_for": "Roles for {{name}}"
},
"settings": {
"title": "Settings",
"fearless_draft": "Fearless Draft",
"fearless_draft_desc": "When enabled, champions selected in one draft are blacklisted from future rerolls until reset.",
"language": "Language",
"languages": {
"en": "English",
"fr": "Français"
}
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"app": {
"title": "Escouade aléatoire de League",
"subtitle": "Attribuez un champion unique pour chaque lane en un clic."
},
"control": {
"roll": "Lancer les champions",
"copy": "Copier le draft",
"reset": "Réinitialiser la liste noire",
"settings": "Paramètres",
"hint": "Chaque tir garantit des champions uniques pour chaque lane.",
"loading": "Chargement des données des champions…",
"retry": "Réessayer"
},
"lane": {
"disabled_subtitle": "Lane actuellement désactivée",
"disabled_title": "Cette lane a été désactivée",
"disabled_detail": "Vous avez choisi d'exclure cette lane de la sélection aléatoire.",
"enable": "Activer la lane",
"locked": "Choix unique verrouillé",
"needed": "Champion requis pour cette lane",
"no_champion": "Aucun champion disponible",
"roll_again_hint": "Relancez une fois que les champions ont fini de charger.",
"reroll": "Relancer !",
"no_rerolls_left": "Plus de relances disponibles",
"force_reroll": "Forcer la relance !",
"roles_for": "Rôles pour {{name}}"
},
"settings": {
"title": "Paramètres",
"fearless_draft": "Draft sans peur",
"fearless_draft_desc": "Lorsqu'activé, les champions sélectionnés dans un draft sont mis en liste noire pour les relances futures jusqu'à réinitialisation.",
"language": "Langue",
"languages": {
"en": "English",
"fr": "Français"
}
}
}