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", "@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0", "@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"deep-equality-data-structures": "^1.5.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"mime-types": "^2.1.35", "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 { Title } from '@angular/platform-browser'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { combineLatest, map, merge, startWith } from 'rxjs' import { combineLatest, map, merge, startWith } from 'rxjs'
import { i18nService } from 'src/app/i18n/i18n.service'
import { ConnectionService } from './services/connection.service' import { ConnectionService } from './services/connection.service'
import { PatchDataService } from './services/patch-data.service' import { PatchDataService } from './services/patch-data.service'
import { DataModel } from './services/patch-db/data-model' import { DataModel } from './services/patch-db/data-model'
@@ -15,6 +16,7 @@ import { PatchMonitorService } from './services/patch-monitor.service'
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
private readonly title = inject(Title) private readonly title = inject(Title)
private readonly i18n = inject(i18nService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly subscription = merge( readonly subscription = merge(
@@ -37,9 +39,10 @@ export class AppComponent implements OnInit {
startWith(true), startWith(true),
) )
async ngOnInit() { ngOnInit() {
this.patch this.patch.watch$('ui').subscribe(({ name, language }) => {
.watch$('ui', 'name') this.title.setTitle(name || 'StartOS')
.subscribe(name => this.title.setTitle(name || 'StartOS')) this.i18n.setLanguage(language)
})
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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