Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refacto language + add navigator preferences #349

Merged
merged 10 commits into from
Nov 15, 2024
14 changes: 2 additions & 12 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,6 @@
>
{{ 'app.user-manual' | translate }}
</a>
<sbb-select
(selectionChange)="changeLocale($event.value)"
[value]="locale"
class="language-selector"
>
<sbb-option value="en">🇬🇧 English</sbb-option>
<sbb-option value="de">🇩🇪 Deutsch</sbb-option>
<sbb-option value="fr">🇫🇷 Français</sbb-option>
<!-- <sbb-option value="it">🇮🇹 Italiano</sbb-option> -->
</sbb-select>
</sbb-app-chooser-section>
<sbb-header-environment class="noprint" *ngIf="environmentLabel">{{
environmentLabel
Expand All @@ -35,8 +25,8 @@
</sbb-usermenu>
<sbb-menu #menu="sbbMenu">
<sbb-select
(selectionChange)="changeLocale($event.value)"
[value]="locale"
(selectionChange)="language = $event.value"
[value]="currentLanguage"
class="language-selector language-selector-menu"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
Expand Down
17 changes: 13 additions & 4 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {NetzgrafikDto} from "./data-structures/business.data.structures";
import {Operation} from "./models/operation.model";
import {LabelService} from "./services/data/label.serivce";
import {NodeService} from "./services/data/node.service";
import {I18nService} from "./core/i18n/i18n.service";

@Component({
selector: "sbb-root",
Expand All @@ -20,9 +21,9 @@ import {NodeService} from "./services/data/node.service";
export class AppComponent {
readonly disableBackend = environment.disableBackend;
readonly version = packageJson.version;
readonly locale = localStorage.getItem("locale");
readonly environmentLabel = environment.label;
readonly authenticated: Promise<unknown>;
protected currentLanguage: string = this.i18nService.language;

projectInMenu: Observable<ProjectDto | null>;

Expand All @@ -46,6 +47,7 @@ export class AppComponent {
private trainrunSectionService: TrainrunSectionService,
private nodeService: NodeService,
private labelService: LabelService,
private i18nService: I18nService,
) {
if (!this.disableBackend) {
this.authenticated = authService.initialized;
Expand All @@ -58,9 +60,16 @@ export class AppComponent {
}
}

changeLocale(locale: string) {
localStorage.setItem("locale", locale);
location.reload();
@Input()
get language() {
return this.currentLanguage;
}

set language(language: string) {
if (language !== this.currentLanguage) {
this.i18nService.setLanguage(language);
this.currentLanguage = language;
}
}

@Input()
Expand Down
12 changes: 6 additions & 6 deletions src/app/core/i18n/i18n.module.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import {NgModule, APP_INITIALIZER, LOCALE_ID} from "@angular/core";
import {CommonModule} from "@angular/common";
import {TranslatePipe} from "./translate.pipe";
import {I18n} from "./i18n.service";
import {I18nService} from "./i18n.service";

@NgModule({
declarations: [TranslatePipe], // Declare the pipe
imports: [CommonModule],
providers: [
I18n,
I18nService,
{ // Load locale data at app start-up
provide: APP_INITIALIZER,
useFactory: (i18n: I18n) => () => i18n.setLocale(),
deps: [I18n],
useFactory: (i18nService: I18nService) => () => i18nService.setLanguage(),
deps: [I18nService],
multi: true,
},
{ // Set the runtime locale for the app
provide: LOCALE_ID,
useFactory: (i18n: I18n) => i18n.locale,
deps: [I18n],
useFactory: (i18nService: I18nService) => i18nService.language,
deps: [I18nService],
}
],
exports: [TranslatePipe] // Export the pipe
Expand Down
161 changes: 85 additions & 76 deletions src/app/core/i18n/i18n.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,85 +5,94 @@ import {loadTranslations} from "@angular/localize";
@Injectable({
providedIn: "root",
})
export class I18n {
locale = "en";
readonly allowedLocales = ["en", "fr", "de", "it"];
translations: any = {};

async setLocale() {
const userLocale = localStorage.getItem("locale");

// If the user has a preferred language stored in LocalStorage, use it.
if (userLocale && this.allowedLocales.includes(userLocale)) {
this.locale = userLocale;
} else {
localStorage.setItem("locale", this.locale);
}

// Use webpack magic string to only include required locale data
const localeModule = await import(
/* webpackInclude: /(en|de|fr|it)\.mjs$/ */
`/node_modules/@angular/common/locales/${this.locale}.mjs`
);
registerLocaleData(localeModule.default);

// Load translation file initially
await this.loadTranslations();
}

async loadTranslations() {
const localeTranslationsModule = await import(
`src/assets/i18n/${this.locale}.json`
);

// Ensure translations are flattened if necessary
this.translations = this.flattenTranslations(localeTranslationsModule.default);

// Load translations for the current locale at runtime
loadTranslations(this.translations);
export class I18nService {
readonly allowedLanguages = ["en", "fr", "de", "it"];
private currentLanguage: string = this.getLanguageFromStorage() || this.detectNavigatorLanguage();
translations: any = {};

get language(): string {
return this.currentLanguage;
}

async setLanguage(language?: string) {
if (language && this.allowedLanguages.includes(this.language)) {
this.setLanguageToStorage(language);
this.currentLanguage = language;
}

// Helper function to flatten nested translations
// nested JSON :
// {
// "app": {
// "login": "Login",
// "models": {...}
// }
// }
// flattened JSON :
// {
// "app.login": "Login",
// "app.models...": ...,
// "app.models...": ...
// }
private flattenTranslations(translations: any): any {
const flattenedTranslations = {};

function flatten(obj, prefix = "") {
for (const key in obj) {
if (typeof obj[key] === "string") {
flattenedTranslations[prefix + key] = obj[key];
} else if (typeof obj[key] === "object") {
flatten(obj[key], prefix + key + ".");
}

const languageModule = await import(
/* webpackInclude: /(en|de|fr|it)\.mjs$/ */
`/node_modules/@angular/common/locales/${this.language}.mjs`
);
registerLocaleData(languageModule.default);

await this.loadTranslations();
}

private setLanguageToStorage(language: string) {
localStorage.setItem("i18nLng", language);
}

private getLanguageFromStorage(): string | null {
const lang = localStorage.getItem("i18nLng");
return this.allowedLanguages.includes(lang) ? lang : null;
}

private detectNavigatorLanguage(): string {
const navigatorLanguage = navigator.language.slice(0, 2);
return this.allowedLanguages.includes(navigatorLanguage) ? navigatorLanguage : this.allowedLanguages[0];
}

async loadTranslations() {
const languageTranslationsModule = await import(
`src/assets/i18n/${this.language}.json`
);

this.translations = this.flattenTranslations(languageTranslationsModule.default);
loadTranslations(this.translations);
}

// Helper function to flatten nested translations
// nested JSON :
// {
// "app": {
// "login": "Login",
// "models": {...}
// }
// }
// flattened JSON :
// {
// "app.login": "Login",
// "app.models...": ...,
// "app.models...": ...
// }
private flattenTranslations(translations: any): any {
const flattenedTranslations = {};

function flatten(obj, prefix = "") {
for (const key in obj) {
if (typeof obj[key] === "string") {
flattenedTranslations[prefix + key] = obj[key];
} else if (typeof obj[key] === "object") {
flatten(obj[key], prefix + key + ".");
}
}

flatten(translations);
return flattenedTranslations;
}

// Used for the pipe and allowing parameters
translate(key: string, params?: any): string {
let translation = this.translations[key] || key;

if (params) {
Object.keys(params).forEach(param => {
translation = translation.replace(`{$${param}}`, params[param]);
});
}

return translation;

flatten(translations);
return flattenedTranslations;
}

// Used for the pipe and allowing parameters
translate(key: string, params?: any): string {
let translation = this.translations[key] || key;

if (params) {
Object.keys(params).forEach(param => {
translation = translation.replace(`{$${param}}`, params[param]);
});
}

return translation;
}
}
6 changes: 3 additions & 3 deletions src/app/core/i18n/translate.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {Pipe, PipeTransform} from "@angular/core";
import {I18n} from "./i18n.service";
import {I18nService} from "./i18n.service";

@Pipe({
name: "translate",
pure: false
})
export class TranslatePipe implements PipeTransform {
constructor(private i18n: I18n) {}
constructor(private i18nService: I18nService) {}

transform(key: string, params?: any): string {
return this.i18n.translate(key, params);
return this.i18nService.translate(key, params);
}
}