feat: add i18n infrastructure (#2854)

* feat: add i18n infrastructure

* store langauge selection to patchDB ui section

* feat: react to patchdb language change

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2025-03-25 15:30:35 +04:00
committed by GitHub
parent 99739575d4
commit 5318cccc5f
14 changed files with 299 additions and 61 deletions

1
web/package-lock.json generated
View File

@@ -125,6 +125,7 @@
"@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"deep-equality-data-structures": "^1.5.0",
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",

View File

@@ -3,6 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Title } from '@angular/platform-browser'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, merge, startWith } from 'rxjs'
import { i18nService } from 'src/app/i18n/i18n.service'
import { ConnectionService } from './services/connection.service'
import { PatchDataService } from './services/patch-data.service'
import { DataModel } from './services/patch-db/data-model'
@@ -15,6 +16,7 @@ import { PatchMonitorService } from './services/patch-monitor.service'
})
export class AppComponent implements OnInit {
private readonly title = inject(Title)
private readonly i18n = inject(i18nService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly subscription = merge(
@@ -37,9 +39,10 @@ export class AppComponent implements OnInit {
startWith(true),
)
async ngOnInit() {
this.patch
.watch$('ui', 'name')
.subscribe(name => this.title.setTitle(name || 'StartOS'))
ngOnInit() {
this.patch.watch$('ui').subscribe(({ name, language }) => {
this.title.setTitle(name || 'StartOS')
this.i18n.setLanguage(language)
})
}
}

View File

@@ -21,6 +21,8 @@ import {
import { tuiTextfieldOptionsProvider } from '@taiga-ui/legacy'
import { PatchDB } from 'patch-db-client'
import { filter, of, pairwise } from 'rxjs'
import { I18N_PROVIDERS } from 'src/app/i18n/i18n.providers'
import { i18nService } from 'src/app/i18n/i18n.service'
import {
PATCH_CACHE,
PatchDbSource,
@@ -43,6 +45,7 @@ const {
export const APP_PROVIDERS: Provider[] = [
NG_EVENT_PLUGINS,
I18N_PROVIDERS,
FilterPackagesPipe,
UntypedFormBuilder,
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
@@ -75,7 +78,6 @@ export const APP_PROVIDERS: Provider[] = [
},
{
provide: APP_INITIALIZER,
deps: [StorageService, AuthService, ClientStorageService, Router],
useFactory: appInitializer,
multi: true,
},
@@ -100,16 +102,18 @@ export const APP_PROVIDERS: Provider[] = [
},
]
export function appInitializer(
storage: StorageService,
auth: AuthService,
localStorage: ClientStorageService,
router: Router,
): () => void {
export function appInitializer(): () => void {
const storage = inject(StorageService)
const auth = inject(AuthService)
const localStorage = inject(ClientStorageService)
const router = inject(Router)
const i18n = inject(i18nService)
return () => {
storage.migrate036()
auth.init()
localStorage.init()
router.initialNavigation()
i18n.setLanguage(i18n.language)
}
}

View File

@@ -0,0 +1,42 @@
export default {
ui: {
back: 'Back',
change: 'Change',
update: 'Update',
reset: 'Reset',
},
system: {
outlet: {
system: 'System',
general: 'General',
email: 'Email',
backup: 'Create Backup',
restore: 'Restore Backup',
interfaces: 'User Interface Addresses',
acme: 'ACME',
wifi: 'WiFi',
sessions: 'Active Sessions',
ssh: 'SSH',
password: 'Change Password',
},
general: {
title: 'General Settings',
subtitle: 'Manage your overall setup and preferences',
update: 'Software Update',
restart: 'Restart to apply',
check: 'Check for updates',
tab: 'Browser Tab Title',
language: 'Language',
tor: 'Reset Tor',
daemon: 'Restart the Tor daemon on your server',
disk: 'Disk Repair',
attempt: 'Attempt automatic repair',
repair: 'Repair',
sync: {
title: 'Clock sync failure',
subtitle:
'This will cause connectivity issues. To resolve it, refer to the',
},
},
},
}

View File

@@ -0,0 +1,44 @@
import type { i18n } from '../i18n.providers'
export default {
ui: {
back: 'Atrás',
change: 'Cambiar',
update: 'Actualizar',
reset: 'Reiniciar',
},
system: {
outlet: {
system: 'Sistema',
general: 'General',
email: 'Correo Electrónico',
backup: 'Crear Copia de Seguridad',
restore: 'Restaurar Copia de Seguridad',
interfaces: 'Direcciones de Interfaz de Usuario',
acme: 'ACME',
wifi: 'WiFi',
sessions: 'Sesiones Activas',
ssh: 'SSH',
password: 'Cambiar Contraseña',
},
general: {
title: 'Configuración General',
subtitle: 'Gestiona tu configuración general y preferencias',
update: 'Actualización de Software',
restart: 'Reiniciar para aplicar',
check: 'Buscar actualizaciones',
tab: 'Título de la Pestaña del Navegador',
language: 'Idioma',
tor: 'Reiniciar Tor',
daemon: 'Reiniciar el daemon de Tor en tu servidor',
disk: 'Reparación de Disco',
attempt: 'Intentar reparación automática',
repair: 'Reparar',
sync: {
title: 'Fallo en la sincronización del reloj',
subtitle:
'Esto causará problemas de conectividad. Para resolverlo, consulta la',
},
},
},
} satisfies i18n

View File

@@ -0,0 +1,23 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { I18N, i18n } from './i18n.providers'
type DeepKeyOf<T> = {
[K in keyof T & string]: T[K] extends {}
? T[K] extends string
? K
: `${K}.${DeepKeyOf<T[K]>}`
: never
}[keyof T & string]
@Pipe({
standalone: true,
name: 'i18n',
pure: false,
})
export class i18nPipe implements PipeTransform {
private readonly i18n = inject(I18N)
transform(path: DeepKeyOf<i18n>): string {
return path.split('.').reduce((acc, part) => acc[part], this.i18n() as any)
}
}

View File

@@ -0,0 +1,38 @@
import { signal } from '@angular/core'
import { tuiCreateToken, tuiProvide } from '@taiga-ui/cdk'
import {
TuiLanguageName,
tuiLanguageSwitcher,
TuiLanguageSwitcherService,
} from '@taiga-ui/i18n'
import ENGLISH from './dictionaries/english'
import { i18nService } from './i18n.service'
export type i18n = typeof ENGLISH
export const I18N = tuiCreateToken(signal(ENGLISH))
export const I18N_LOADER =
tuiCreateToken<(lang: TuiLanguageName) => Promise<i18n>>()
export const I18N_PROVIDERS = [
tuiLanguageSwitcher(async (language: TuiLanguageName): Promise<unknown> => {
switch (language) {
case 'spanish':
return import('@taiga-ui/i18n/languages/spanish')
default:
return import('@taiga-ui/i18n/languages/english')
}
}),
{
provide: I18N_LOADER,
useValue: async (language: TuiLanguageName): Promise<unknown> => {
switch (language) {
case 'spanish':
return import('./dictionaries/spanish').then(v => v.default)
default:
return import('./dictionaries/english').then(v => v.default)
}
},
},
tuiProvide(TuiLanguageSwitcherService, i18nService),
]

View File

@@ -0,0 +1,30 @@
import { inject, Injectable, signal } from '@angular/core'
import { TuiLanguageName, TuiLanguageSwitcherService } from '@taiga-ui/i18n'
import { I18N, I18N_LOADER } from './i18n.providers'
import { ApiService } from '../services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class i18nService extends TuiLanguageSwitcherService {
private readonly i18n = inject(I18N)
private readonly i18nLoader = inject(I18N_LOADER)
private readonly api = inject(ApiService)
readonly loading = signal(false)
override setLanguage(language: TuiLanguageName): void {
if (this.language === language) {
return
}
super.setLanguage(language)
this.loading.set(true)
this.api.setDbValue(['language'], language).then(() =>
this.i18nLoader(language).then(value => {
this.i18n.set(value)
this.loading.set(false)
}),
)
}
}

View File

@@ -6,6 +6,7 @@ import {
INJECTOR,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
@@ -16,13 +17,21 @@ import {
tuiFadeIn,
TuiIcon,
tuiScaleIn,
TuiTextfield,
TuiTitle,
} from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import {
TUI_CONFIRM,
TuiButtonLoading,
TuiButtonSelect,
TuiDataListWrapper,
} from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider, TuiHeader } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { i18nPipe } from 'src/app/i18n/i18n.pipe'
import { i18nService } from 'src/app/i18n/i18n.service'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
@@ -36,13 +45,19 @@ import { SystemWipeComponent } from './wipe.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
General Settings
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'ui.back' | i18n }}
</a>
{{ 'system.general.title' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>General</h3>
<p tuiSubtitle>Manage your overall setup and preferences</p>
<h3>
{{ 'system.general.title' | i18n }}
</h3>
<p tuiSubtitle>
{{ 'system.general.subtitle' | i18n }}
</p>
</hgroup>
</header>
@if (server(); as server) {
@@ -52,7 +67,9 @@ import { SystemWipeComponent } from './wipe.component'
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" />
<span tuiTitle>
<strong>Software Update</strong>
<strong>
{{ 'system.general.update' | i18n }}
</strong>
<span tuiSubtitle>{{ server.version }}</span>
</span>
<button
@@ -62,12 +79,12 @@ import { SystemWipeComponent } from './wipe.component'
(click)="onUpdate()"
>
@if (server.statusInfo.updated) {
Restart to apply
{{ 'system.general.restart' | i18n }}
} @else {
@if (eos.showUpdate$ | async) {
Update
{{ 'ui.update' | i18n }}
} @else {
Check for updates
{{ 'system.general.check' | i18n }}
}
}
</button>
@@ -75,36 +92,65 @@ import { SystemWipeComponent } from './wipe.component'
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>Browser Tab Title</strong>
<strong>
{{ 'system.general.tab' | i18n }}
</strong>
<span tuiSubtitle>{{ name() }}</span>
</span>
<button tuiButton (click)="onTitle()">Change</button>
<button tuiButton (click)="onTitle()">
{{ 'ui.change' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.languages" />
<span tuiTitle>
<strong>Language</strong>
<span tuiSubtitle>English</span>
<strong>
{{ 'system.general.language' | i18n }}
</strong>
<span tuiSubtitle>{{ i18n.language }}</span>
</span>
<button tuiButton>Change</button>
<button
tuiButtonSelect
tuiButton
[loading]="i18n.loading()"
[ngModel]="i18n.language"
(ngModelChange)="i18n.setLanguage($event)"
>
{{ 'ui.change' | i18n }}
<tui-data-list-wrapper
*tuiTextfieldDropdown
size="l"
[items]="languages"
/>
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
<span tuiTitle>
<strong>Reset Tor</strong>
<span tuiSubtitle>Restart the Tor daemon on your server</span>
<strong>
{{ 'system.general.tor' | i18n }}
</strong>
<span tuiSubtitle>
{{ 'system.general.daemon' | i18n }}
</span>
</span>
<button tuiButton appearance="glass" (click)="onReset()">Reset</button>
<button tuiButton appearance="glass" (click)="onReset()">
{{ 'ui.reset' | i18n }}
</button>
</div>
@if (count > 4) {
<div tuiCell tuiAppearance="outline-grayscale" @tuiScaleIn @tuiFadeIn>
<tui-icon icon="@tui.briefcase-medical" />
<span tuiTitle>
<strong>Disk Repair</strong>
<span tuiSubtitle>Attempt automatic repair</span>
<strong>
{{ 'system.general.disk' | i18n }}
</strong>
<span tuiSubtitle>
{{ 'system.general.attempt' | i18n }}
</span>
</span>
<button tuiButton appearance="glass" (click)="onRepair()">
Repair
{{ 'system.general.repair' | i18n }}
</button>
</div>
}
@@ -122,6 +168,11 @@ import { SystemWipeComponent } from './wipe.component'
[tuiCell] {
background: var(--tui-background-neutral-1);
}
[tuiSubtitle],
tui-data-list-wrapper ::ng-deep [tuiOption] {
text-transform: capitalize;
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -130,14 +181,20 @@ import { SystemWipeComponent } from './wipe.component'
imports: [
AsyncPipe,
RouterLink,
i18nPipe,
TuiTitle,
TuiHeader,
TuiCell,
TuiAppearance,
TuiButton,
TuiIcon,
TitleDirective,
SystemSyncComponent,
TuiIcon,
TuiButtonLoading,
TuiButtonSelect,
TuiDataListWrapper,
TuiTextfield,
FormsModule,
],
})
export default class SystemGeneralComponent {
@@ -159,6 +216,8 @@ export default class SystemGeneralComponent {
readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly name = toSignal(this.patch.watch$('ui', 'name'))
readonly eos = inject(EOSService)
readonly i18n = inject(i18nService)
readonly languages = ['english', 'spanish']
onUpdate() {
if (this.server()?.statusInfo.updated) {

View File

@@ -1,14 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiLink, TuiNotification, TuiTitle } from '@taiga-ui/core'
import { i18nPipe } from 'src/app/i18n/i18n.pipe'
@Component({
selector: 'system-sync',
template: `
<tui-notification appearance="warning">
<div tuiTitle>
Clock sync failure
{{ 'system.general.sync.title' | i18n }}
<div tuiSubtitle>
This will cause connectivity issues. Refer to the
{{ 'system.general.sync.subtitle' | i18n }}
<a
tuiLink
iconEnd="@tui.external-link"
@@ -17,13 +18,12 @@ import { TuiLink, TuiNotification, TuiTitle } from '@taiga-ui/core'
rel="noreferrer"
[textContent]="'StartOS docs'"
></a>
to resolve it
</div>
</div>
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiNotification, TuiTitle, TuiLink],
imports: [TuiNotification, TuiTitle, TuiLink, i18nPipe],
})
export class SystemSyncComponent {}

View File

@@ -4,13 +4,14 @@ import { RouterModule } from '@angular/router'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiBadgeNotification } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { i18nPipe } from 'src/app/i18n/i18n.pipe'
import { BadgeService } from 'src/app/services/badge.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SYSTEM_MENU } from './system.const'
@Component({
template: `
<span *title>System</span>
<span *title>{{ 'system.outlet.general' | i18n }}</span>
<aside class="g-aside">
@for (cat of menu; track $index) {
@if ($index) {
@@ -20,13 +21,13 @@ import { SYSTEM_MENU } from './system.const'
<a
tuiCell="s"
routerLinkActive="active"
[routerLink]="page.routerLink"
[routerLink]="page.item.split('.').at(-1)"
>
<tui-icon [icon]="page.icon" />
<span tuiTitle>
<span>
{{ page.title }}
@if (page.routerLink === 'general' && badge()) {
{{ page.item | i18n }}
@if (page.item === 'system.outlet.general' && badge()) {
<tui-badge-notification>{{ badge() }}</tui-badge-notification>
}
</span>
@@ -106,6 +107,7 @@ import { SYSTEM_MENU } from './system.const'
TuiTitle,
TitleDirective,
TuiBadgeNotification,
i18nPipe,
],
})
export class SystemComponent {

View File

@@ -1,60 +1,50 @@
export const SYSTEM_MENU = [
[
{
title: 'General',
icon: '@tui.settings',
routerLink: 'general',
item: 'system.outlet.general',
},
{
title: 'Email',
icon: '@tui.mail',
routerLink: 'email',
item: 'system.outlet.email',
},
],
[
{
title: 'Create Backup',
icon: '@tui.copy-plus',
routerLink: 'backup',
item: 'system.outlet.backup',
},
{
title: 'Restore Backup',
icon: '@tui.database-backup',
routerLink: 'restore',
item: 'system.outlet.restore',
},
],
[
{
title: 'User Interface Addresses',
icon: '@tui.monitor',
routerLink: 'interfaces',
item: 'system.outlet.interfaces',
},
{
title: 'ACME',
icon: '@tui.award',
routerLink: 'acme',
item: 'system.outlet.acme',
},
{
title: 'WiFi',
icon: '@tui.wifi',
routerLink: 'wifi',
item: 'system.outlet.wifi',
},
],
[
{
title: 'Active Sessions',
icon: '@tui.clock',
routerLink: 'sessions',
item: 'system.outlet.sessions',
},
{
title: 'SSH',
icon: '@tui.terminal',
routerLink: 'ssh',
item: 'system.outlet.ssh',
},
{
title: 'Change Password',
icon: '@tui.key',
routerLink: 'password',
item: 'system.outlet.password',
},
],
]
] as const

View File

@@ -24,6 +24,7 @@ export const mockPatchData: DataModel = {
highScore: 0,
},
},
language: 'english',
ackInstructions: {},
},
serverInfo: {

View File

@@ -12,6 +12,7 @@ export type UIData = {
}
ackInstructions: Record<string, boolean>
theme: string
language: 'english' | 'spanish'
}
export type UIMarketplaceData = {