Skip to content

Commit

Permalink
Refacto language + add navigator preferences (#349)
Browse files Browse the repository at this point in the history
* feat: initialize with navigator language

* rename: use language instead of locale

* feat: set language as DOM property for external use

* refacto: change language without reload + external language setter

* feat: remove side menu language selector

* fix comments

* let localstorage empty if not explicitly select language

* lint

* refacto localStorage logic

Signed-off-by: Louis Greiner <[email protected]>

* add validation for localStorage getter

Signed-off-by: Louis Greiner <[email protected]>

---------

Signed-off-by: Louis Greiner <[email protected]>
  • Loading branch information
louisgreiner authored Nov 15, 2024
1 parent e0330ff commit 338b69f
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 101 deletions.
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);
}
}

0 comments on commit 338b69f

Please sign in to comment.