diff --git a/web/package-lock.json b/web/package-lock.json index 119a46e7f..26b9d7c95 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/projects/ui/src/app/app.component.ts b/web/projects/ui/src/app/app.component.ts index 3d83b2c79..d51e3b675 100644 --- a/web/projects/ui/src/app/app.component.ts +++ b/web/projects/ui/src/app/app.component.ts @@ -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) 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) + }) } } diff --git a/web/projects/ui/src/app/app.providers.ts b/web/projects/ui/src/app/app.providers.ts index f4549492d..a6cdf76c3 100644 --- a/web/projects/ui/src/app/app.providers.ts +++ b/web/projects/ui/src/app/app.providers.ts @@ -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) } } diff --git a/web/projects/ui/src/app/i18n/dictionaries/english.ts b/web/projects/ui/src/app/i18n/dictionaries/english.ts new file mode 100644 index 000000000..e7331678a --- /dev/null +++ b/web/projects/ui/src/app/i18n/dictionaries/english.ts @@ -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', + }, + }, + }, +} diff --git a/web/projects/ui/src/app/i18n/dictionaries/spanish.ts b/web/projects/ui/src/app/i18n/dictionaries/spanish.ts new file mode 100644 index 000000000..82c915987 --- /dev/null +++ b/web/projects/ui/src/app/i18n/dictionaries/spanish.ts @@ -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 diff --git a/web/projects/ui/src/app/i18n/i18n.pipe.ts b/web/projects/ui/src/app/i18n/i18n.pipe.ts new file mode 100644 index 000000000..bc8c7f03f --- /dev/null +++ b/web/projects/ui/src/app/i18n/i18n.pipe.ts @@ -0,0 +1,23 @@ +import { inject, Pipe, PipeTransform } from '@angular/core' +import { I18N, i18n } from './i18n.providers' + +type DeepKeyOf = { + [K in keyof T & string]: T[K] extends {} + ? T[K] extends string + ? K + : `${K}.${DeepKeyOf}` + : never +}[keyof T & string] + +@Pipe({ + standalone: true, + name: 'i18n', + pure: false, +}) +export class i18nPipe implements PipeTransform { + private readonly i18n = inject(I18N) + + transform(path: DeepKeyOf): string { + return path.split('.').reduce((acc, part) => acc[part], this.i18n() as any) + } +} diff --git a/web/projects/ui/src/app/i18n/i18n.providers.ts b/web/projects/ui/src/app/i18n/i18n.providers.ts new file mode 100644 index 000000000..4d44720b9 --- /dev/null +++ b/web/projects/ui/src/app/i18n/i18n.providers.ts @@ -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>() + +export const I18N_PROVIDERS = [ + tuiLanguageSwitcher(async (language: TuiLanguageName): Promise => { + 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 => { + switch (language) { + case 'spanish': + return import('./dictionaries/spanish').then(v => v.default) + default: + return import('./dictionaries/english').then(v => v.default) + } + }, + }, + tuiProvide(TuiLanguageSwitcherService, i18nService), +] diff --git a/web/projects/ui/src/app/i18n/i18n.service.ts b/web/projects/ui/src/app/i18n/i18n.service.ts new file mode 100644 index 000000000..f40f92b57 --- /dev/null +++ b/web/projects/ui/src/app/i18n/i18n.service.ts @@ -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) + }), + ) + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index a830bd4e9..bdc30d25f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -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: ` - Back - General Settings + + {{ 'ui.back' | i18n }} + + {{ 'system.general.title' | i18n }}
-

General

-

Manage your overall setup and preferences

+

+ {{ 'system.general.title' | i18n }} +

+

+ {{ 'system.general.subtitle' | i18n }} +

@if (server(); as server) { @@ -52,7 +67,9 @@ import { SystemWipeComponent } from './wipe.component'
- Software Update + + {{ 'system.general.update' | i18n }} + {{ server.version }} @@ -75,36 +92,65 @@ import { SystemWipeComponent } from './wipe.component'
- Browser Tab Title + + {{ 'system.general.tab' | i18n }} + {{ name() }} - +
- Language - English + + {{ 'system.general.language' | i18n }} + + {{ i18n.language }} - +
- Reset Tor - Restart the Tor daemon on your server + + {{ 'system.general.tor' | i18n }} + + + {{ 'system.general.daemon' | i18n }} + - +
@if (count > 4) {
- Disk Repair - Attempt automatic repair + + {{ 'system.general.disk' | i18n }} + + + {{ 'system.general.attempt' | i18n }} +
} @@ -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) { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/sync.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/sync.component.ts index 2397fbeb4..9d2c27f99 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/sync.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/sync.component.ts @@ -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: `
- Clock sync failure + {{ 'system.general.sync.title' | i18n }}
- This will cause connectivity issues. Refer to the + {{ 'system.general.sync.subtitle' | i18n }} - to resolve it
`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [TuiNotification, TuiTitle, TuiLink], + imports: [TuiNotification, TuiTitle, TuiLink, i18nPipe], }) export class SystemSyncComponent {} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts index be34b3141..c73cc768f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.component.ts @@ -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: ` - System + {{ 'system.outlet.general' | i18n }}