diff --git a/src/client/package.json b/src/client/package.json index 376b377..2a0d1fc 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -33,6 +33,7 @@ "@angular/service-worker": "^18.0.2", "@microsoft/signalr": "^8.0.0", "@ngneers/easy-ngrx-distinct-selector": "^0.1.1", + "@ngneers/signal-translate": "^0.1.0", "@ngrx/effects": "18.0.0-rc.0", "@ngrx/entity": "18.0.0-rc.0", "@ngrx/operators": "18.0.0-rc.0", diff --git a/src/client/pnpm-lock.yaml b/src/client/pnpm-lock.yaml index 55d036b..2026db8 100644 --- a/src/client/pnpm-lock.yaml +++ b/src/client/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@ngneers/easy-ngrx-distinct-selector': specifier: ^0.1.1 version: 0.1.1(@ngrx/store@18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1)) + '@ngneers/signal-translate': + specifier: ^0.1.0 + version: 0.1.0(@angular/common@18.0.2(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7)) '@ngrx/effects': specifier: 18.0.0-rc.0 version: 18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(@ngrx/store@18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(rxjs@7.8.1) @@ -1880,6 +1883,12 @@ packages: peerDependencies: prettier: '>= 3' + '@ngneers/signal-translate@0.1.0': + resolution: {integrity: sha512-paGwqgt0heBGCc9GdfbzAzjvsgnehXW+DCeqoFbA71e+A9RhGuWjxL64CRAJBo7hDyggmlsMyHgFEwZyEYo4Fw==} + peerDependencies: + '@angular/common': 17 || 18 + '@angular/core': 17 || 18 + '@ngrx/effects@18.0.0-rc.0': resolution: {integrity: sha512-nlD0DW4pzE5YPvFM++jnK7reE9i3wvbYhOLQ4adbMJ/+SByBp1FaajJRWdl/YjnWN2uPmQ1HZ5HvXSXQKaY1gg==} peerDependencies: @@ -8297,6 +8306,12 @@ snapshots: dependencies: prettier: 3.3.1 + '@ngneers/signal-translate@0.1.0(@angular/common@18.0.2(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))': + dependencies: + '@angular/common': 18.0.2(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1) + '@angular/core': 18.0.2(rxjs@7.8.1)(zone.js@0.14.7) + tslib: 2.6.3 + '@ngrx/effects@18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(@ngrx/store@18.0.0-rc.0(@angular/core@18.0.2(rxjs@7.8.1)(zone.js@0.14.7))(rxjs@7.8.1))(rxjs@7.8.1)': dependencies: '@angular/core': 18.0.2(rxjs@7.8.1)(zone.js@0.14.7) diff --git a/src/client/src/app/components/events/event-details/event-details.component.ts b/src/client/src/app/components/events/event-details/event-details.component.ts index 713df60..c33369d 100644 --- a/src/client/src/app/components/events/event-details/event-details.component.ts +++ b/src/client/src/app/components/events/event-details/event-details.component.ts @@ -2,6 +2,7 @@ import { CommonModule, formatDate } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { InterpolatePipe, interpolate } from '@ngneers/signal-translate'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { AccordionModule } from 'primeng/accordion'; @@ -26,7 +27,6 @@ import { keepEventLoaded } from '../../../+state/events/events.utils'; import { mapSelectors } from '../../../+state/maps'; import { keepMapsLoaded } from '../../../+state/maps/maps.utils'; import { keepUsersLoaded, userSelectors } from '../../../+state/users'; -import { InterpolatePipe, interpolate } from '../../../directives/interpolate.pipe'; import { TranslateService } from '../../../services/translate.service'; import { ifTruthy } from '../../../utils/common.utils'; import { compareTimes } from '../../../utils/date.utils'; diff --git a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts index e9832d8..678e0a8 100644 --- a/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts +++ b/src/client/src/app/components/events/event-timeslot/event-timeslot.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; +import { InterpolatePipe, interpolate } from '@ngneers/signal-translate'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { AccordionModule } from 'primeng/accordion'; @@ -35,7 +36,6 @@ import { selectUsersActionState, userSelectors, } from '../../../+state/users'; -import { InterpolatePipe, interpolate } from '../../../directives/interpolate.pipe'; import { EventInstancePreconfiguration, User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; import { ifTruthy, isNullish } from '../../../utils/common.utils'; diff --git a/src/client/src/app/components/login/login.component.ts b/src/client/src/app/components/login/login.component.ts index 80f1061..b7ade61 100644 --- a/src/client/src/app/components/login/login.component.ts +++ b/src/client/src/app/components/login/login.component.ts @@ -10,6 +10,7 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { InterpolatePipe } from '@ngneers/signal-translate'; import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; import { InputTextModule } from 'primeng/inputtext'; @@ -19,7 +20,6 @@ import { TooltipModule } from 'primeng/tooltip'; import { distinctUntilChanged, map, startWith } from 'rxjs'; import { ErrorTextDirective } from '../../directives/error-text.directive'; -import { InterpolatePipe } from '../../directives/interpolate.pipe'; import { OnEnterDirective } from '../../directives/on-enter.directive'; import { AuthService, SignInResult } from '../../services/auth.service'; import { Logger } from '../../services/logger.service'; diff --git a/src/client/src/app/components/maps/map-dialog/map-dialog.component.ts b/src/client/src/app/components/maps/map-dialog/map-dialog.component.ts index 094cf1a..03bf129 100644 --- a/src/client/src/app/components/maps/map-dialog/map-dialog.component.ts +++ b/src/client/src/app/components/maps/map-dialog/map-dialog.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { InterpolatePipe } from '@ngneers/signal-translate'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { ButtonModule } from 'primeng/button'; @@ -18,7 +19,6 @@ import { MessagesModule } from 'primeng/messages'; import { isActionBusy } from '../../../+state/action-state'; import { addMapAction, selectMapsActionState, updateMapAction } from '../../../+state/maps'; -import { InterpolatePipe } from '../../../directives/interpolate.pipe'; import { MinigolfMap } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; import { selectSignal } from '../../../utils/ngrx.utils'; diff --git a/src/client/src/app/components/maps/maps.component.ts b/src/client/src/app/components/maps/maps.component.ts index 18c45fb..8fe5991 100644 --- a/src/client/src/app/components/maps/maps.component.ts +++ b/src/client/src/app/components/maps/maps.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; +import { interpolate } from '@ngneers/signal-translate'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { ConfirmationService, MessageService } from 'primeng/api'; @@ -15,7 +16,6 @@ import { MapItemComponent } from './map-item/map-item.component'; import { hasActionFailed, isActionBusy } from '../../+state/action-state'; import { mapSelectors, removeMapAction, selectMapsActionState } from '../../+state/maps'; import { keepMapsLoaded } from '../../+state/maps/maps.utils'; -import { interpolate } from '../../directives/interpolate.pipe'; import { MinigolfMap } from '../../models/parsed-models'; import { TranslateService } from '../../services/translate.service'; import { notNullish } from '../../utils/common.utils'; diff --git a/src/client/src/app/components/player-events/player-events.component.ts b/src/client/src/app/components/player-events/player-events.component.ts index c1fd782..ea6506f 100644 --- a/src/client/src/app/components/player-events/player-events.component.ts +++ b/src/client/src/app/components/player-events/player-events.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; +import { InterpolatePipe } from '@ngneers/signal-translate'; import { MessagesModule } from 'primeng/messages'; import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { map, timer } from 'rxjs'; @@ -9,7 +10,6 @@ import { map, timer } from 'rxjs'; import { hasActionFailed, isActionBusy } from '../../+state/action-state'; import { playerEventSelectors, selectPlayerEventsActionState } from '../../+state/player-events'; import { keepPlayerEventsLoaded } from '../../+state/player-events/player-events.utils'; -import { InterpolatePipe } from '../../directives/interpolate.pipe'; import { PlayerEvent } from '../../models/parsed-models'; import { AuthService } from '../../services/auth.service'; import { TranslateService } from '../../services/translate.service'; diff --git a/src/client/src/app/components/users/user-created-dialog/user-created-dialog.component.ts b/src/client/src/app/components/users/user-created-dialog/user-created-dialog.component.ts index f0ada1e..dcfd29d 100644 --- a/src/client/src/app/components/users/user-created-dialog/user-created-dialog.component.ts +++ b/src/client/src/app/components/users/user-created-dialog/user-created-dialog.component.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { interpolate, InterpolatePipe } from '@ngneers/signal-translate'; import copyToClipboard from 'copy-to-clipboard'; import { MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; -import { interpolate, InterpolatePipe } from '../../../directives/interpolate.pipe'; import { User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; diff --git a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts index 2034f42..c544cf0 100644 --- a/src/client/src/app/components/users/user-dialog/user-dialog.component.ts +++ b/src/client/src/app/components/users/user-dialog/user-dialog.component.ts @@ -10,6 +10,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { InterpolatePipe } from '@ngneers/signal-translate'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import copyToClipboard from 'copy-to-clipboard'; @@ -35,7 +36,6 @@ import { updateUserAction, userSelectors, } from '../../../+state/users'; -import { InterpolatePipe } from '../../../directives/interpolate.pipe'; import { User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; import { areArraysEqual } from '../../../utils/array.utils'; diff --git a/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts index 24c6517..648f47f 100644 --- a/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts +++ b/src/client/src/app/components/users/user-push-dialog/user-push-dialog.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { InterpolatePipe } from '@ngneers/signal-translate'; import { AutoFocusModule } from 'primeng/autofocus'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; @@ -8,7 +9,6 @@ import { InputTextModule } from 'primeng/inputtext'; import { InputTextareaModule } from 'primeng/inputtextarea'; import { NotificationsService } from '../../../api/services'; -import { InterpolatePipe } from '../../../directives/interpolate.pipe'; import { User } from '../../../models/parsed-models'; import { TranslateService } from '../../../services/translate.service'; diff --git a/src/client/src/app/components/users/users.component.ts b/src/client/src/app/components/users/users.component.ts index 6b97b03..3da1514 100644 --- a/src/client/src/app/components/users/users.component.ts +++ b/src/client/src/app/components/users/users.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { interpolate } from '@ngneers/signal-translate'; import { Store } from '@ngrx/store'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; @@ -18,7 +19,6 @@ import { selectUsersActionState, userSelectors, } from '../../+state/users'; -import { interpolate } from '../../directives/interpolate.pipe'; import { User } from '../../models/parsed-models'; import { TranslateService } from '../../services/translate.service'; import { notNullish } from '../../utils/common.utils'; diff --git a/src/client/src/app/directives/error-text.directive.ts b/src/client/src/app/directives/error-text.directive.ts index f63639e..d968da5 100644 --- a/src/client/src/app/directives/error-text.directive.ts +++ b/src/client/src/app/directives/error-text.directive.ts @@ -7,8 +7,8 @@ import { inject, input, } from '@angular/core'; +import { interpolate } from '@ngneers/signal-translate'; -import { interpolate } from './interpolate.pipe'; import { TranslateService } from '../services/translate.service'; @Directive({ diff --git a/src/client/src/app/directives/interpolate.pipe.ts b/src/client/src/app/directives/interpolate.pipe.ts deleted file mode 100644 index 30d5c4d..0000000 --- a/src/client/src/app/directives/interpolate.pipe.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -export function interpolate( - value: string, - params: Record | null | undefined -): string { - if (!params) return value; - - const placeholderRegex = /{{\s*([\w.]+)\s*}}/g; - - let match: RegExpExecArray | null; - let result = ''; - let lastIndex = 0; - while ((match = placeholderRegex.exec(value))) { - const [placeholder, key] = match; - result += value.slice(lastIndex, match.index) + String(getDeepValue(params, key)); - lastIndex = match.index + placeholder.length; - } - - return result + value.slice(lastIndex); -} - -@Pipe({ - name: 'interpolate', - standalone: true, -}) -export class InterpolatePipe implements PipeTransform { - public transform(value: string, params: Record | null | undefined): string { - return interpolate(value, params); - } -} - -function getDeepValue(obj: Record, path: string): unknown { - return path - .split('.') - .reduce((acc, key) => (acc as Record)?.[key] as Record, obj); -} diff --git a/src/client/src/app/services/translate.service.ts b/src/client/src/app/services/translate.service.ts index 60201af..8e1be98 100644 --- a/src/client/src/app/services/translate.service.ts +++ b/src/client/src/app/services/translate.service.ts @@ -1,5 +1,6 @@ import { registerLocaleData } from '@angular/common'; -import { Injectable, Signal, computed, effect, inject, signal } from '@angular/core'; +import { effect, inject, Injectable } from '@angular/core'; +import { BaseTranslateService, TranslateKeys } from '@ngneers/signal-translate'; import { PrimeNGConfig } from 'primeng/api'; import { getLocalStorage, setLocalStorage } from '../utils/local-storage.utils'; @@ -18,76 +19,28 @@ const langs: Record Promise> = { de: () => import('../i18n/de').then(x => x.default), }; -type TranslateKeys = T extends object - ? T extends unknown[] - ? never - : T extends Set - ? never - : T extends Map - ? never - : T extends Function - ? never - : { - [K in keyof T]: K extends string - ? T[K] extends string - ? K - : `${K}_${TranslateKeys}` - : never; - }[keyof T] - : never; -export type TranslationKey = TranslateKeys; - const langLocalStorageKey = 'lang'; +export type TranslationKey = TranslateKeys; + @Injectable({ providedIn: 'root' }) -export class TranslateService { +export class TranslateService extends BaseTranslateService { private readonly _primengConfig = inject(PrimeNGConfig); - private readonly _translations = signal(undefined); - private readonly _language = signal(getLocalStorage(langLocalStorageKey)); - - public readonly translations = toTranslationsSignal(this._translations); - public readonly browserLanguage = computed(() => navigator.language ?? 'en'); - public readonly language = computed(() => this._language() ?? this.browserLanguage()); - constructor() { - effect(() => { - const lang = this.language(); - - document.documentElement.lang = lang; + super(['en', 'de'], getLocalStorage(langLocalStorageKey)); - let getTranslations = langs[lang]; - if (!getTranslations && lang.includes('-')) { - getTranslations = langs[lang.split('-')[0]]; - } - if (!getTranslations) { - getTranslations = langs['en']; + effect(() => { + if (this.isLanguage(null)) { + setLocalStorage(langLocalStorageKey, null); + } else { + setLocalStorage(langLocalStorageKey, this.language()); } - - getTranslations().then(({ translations, locale, localeExtra, primengTranslations }) => { - this._translations.set(JSON.parse(JSON.stringify(translations))); - registerLocaleData(locale, lang, localeExtra); - this._primengConfig.setTranslation(primengTranslations); - }); }); } - public translate(key: string) { - const path = key.split('_'); - return (getDeepValue(this.translations(), path) as string) ?? key; - } - - public setLanguage(language: string | null) { - setLocalStorage(langLocalStorageKey, language); - this._language.set(language); - } - - public isLanguage(language: string | null) { - return this._language() === language; - } - public getLangDisplay(lang: string | null) { - lang ??= this.browserLanguage(); + lang ??= this.browserLanguage; switch (lang) { case 'en': return 'English'; @@ -97,36 +50,11 @@ export class TranslateService { return lang; } } -} -export function isFunctionKey(key: string): key is Extract { - return (key in Function.prototype && key !== 'constructor') || key === 'prototype'; -} -export type TranslationsSignal = Signal & - Readonly<{ - [K in TranslateKeys]: Signal & { key: K }; - }>; -function toTranslationsSignal>( - signal: Signal -): TranslationsSignal { - return new Proxy(signal, { - get(target: Signal & Record, prop) { - if (typeof prop !== 'string' || isFunctionKey(prop)) { - return target[prop]; - } - const sig = computed(() => getDeepValue(target(), prop.split('_'))); - Object.defineProperty(sig, 'key', { value: prop }); - return sig; - }, - }) as TranslationsSignal; -} - -function getDeepValue | undefined>( - obj: T, - path: string[] -): unknown { - return path.reduce | null | undefined>( - (xs, x) => (xs && xs[x] ? (xs[x] as Record) : null), - obj - ); + protected override async loadTranslations(lang: string): Promise { + const { translations, locale, localeExtra, primengTranslations } = await langs[lang](); + registerLocaleData(locale, lang, localeExtra); + this._primengConfig.setTranslation(primengTranslations); + return translations; + } }