diff --git a/web/package-lock.json b/web/package-lock.json
index a23c11ed2..d61efeaf1 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -386,7 +386,6 @@
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-20.3.13.tgz",
"integrity": "sha512-/D84T1Caxll3I2sRihPDR9UaWBhF50M+tAX15PdP6uSh/TxwAlLl9p7Rm1bD0mPjPercqaEKA+h9a9qLP16hug==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ajv": "8.17.1",
"ajv-formats": "3.0.1",
@@ -414,7 +413,6 @@
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.13.tgz",
"integrity": "sha512-hdMKY4rUTko8xqeWYGnwwDYDomkeOoLsYsP6SdaHWK7hpGvzWsT6Q/aIv8J8NrCYkLu+M+5nLiKOooweUZu3GQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@angular-devkit/core": "20.3.13",
"jsonc-parser": "3.3.1",
@@ -451,7 +449,6 @@
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz",
"integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -566,7 +563,6 @@
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.14.tgz",
"integrity": "sha512-7bZxc01URbiPiIBWThQ69XwOxVduqEKN4PhpbF2AAyfMc/W8Hcr4VoIJOwL0O1Nkq5beS8pCAqoOeIgFyXd/kg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
@@ -583,7 +579,6 @@
"integrity": "sha512-G78I/HDJULloS2LSqfUfbmBlhDCbcWujIRWfuMnGsRf82TyGA2OEPe3IA/F8MrJfeOzPQim2fMyn24MqHL40Vg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@angular-devkit/architect": "0.2003.13",
"@angular-devkit/core": "20.3.13",
@@ -618,7 +613,6 @@
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz",
"integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -635,7 +629,6 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz",
"integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -649,7 +642,6 @@
"integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/core": "7.28.3",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -682,7 +674,6 @@
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz",
"integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -708,7 +699,6 @@
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz",
"integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -737,7 +727,6 @@
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz",
"integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -802,7 +791,6 @@
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz",
"integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -821,7 +809,6 @@
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-20.3.15.tgz",
"integrity": "sha512-HCptODPVWg30XJwSueOz2zqsJjQ1chSscTs7FyIQcfuCTTthO35Lvz2Gtct8/GNHel9QNvvVwA5jrLjsU4dt1A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -867,7 +854,6 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1832,7 +1818,6 @@
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.2.1",
"@inquirer/confirm": "^5.1.14",
@@ -3451,7 +3436,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -3971,7 +3955,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.66.0.tgz",
"integrity": "sha512-tRWyuqK5j5nEjlk0x5HaeLArgVpAIJZNeMiPy//95v4/8tlHdQLM4gh3qcvwS70GN5fnlFXINWhnblvxSDv2dw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": ">=2.8.1"
},
@@ -4037,7 +4020,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.66.0.tgz",
"integrity": "sha512-5DFbwHo7JHKBjgizbGTaIRJsai20+ZknhOQ1SRYwRTc9+6C1HbY/gGC+cjJTLmEQvk14rOoz8qbeWzJx88BU2Q==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "2.8.1"
},
@@ -4069,7 +4051,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.66.0.tgz",
"integrity": "sha512-AjjH+xhgonjf9Xnx3SHNrP5VbsS9jdtGB3BCTQbicYd6QuujQBKldK0fnYMjCY3L0+lboI2OPCVg9PTliOdJ8A==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": ">=2.8.1"
},
@@ -4109,7 +4090,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.7.0.tgz",
"integrity": "sha512-j3HPRPR7XxKxgMeytb+r/CNUoLBMVrfdfL8KJr1XiFO9jyEvoC4chFXDXWlkGyUHJIC6wy5VIXlIlI/kpqOiGg==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -4168,7 +4148,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.66.0.tgz",
"integrity": "sha512-uqY3wslMs7KiBceaHPwCyWVrP8IPqb3OgAy1zd5DHosoUj/ciUl4JWVdx+QdsDypV/Cs4EZrqcIUtMDKQ/Zk0g==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": ">=2.8.1"
},
@@ -4197,7 +4176,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.66.0.tgz",
"integrity": "sha512-D6REwySoaPGZlkdqTfrWahMqziXOY7GGTm1pXWVYDi5kEcSP9+F8ojo6saHDlwhN+V4/2jlMrkseSPlfXbmngQ==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": ">=2.8.1"
},
@@ -4216,7 +4194,6 @@
"resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.9.0.tgz",
"integrity": "sha512-TbIIwslbEnxunKuL9OyPZdmefrvJEK6HYiADEKQHUMUs4Pk2UbhMckUieURo83yPDamk/Mww+Nu/g60J/4uh2w==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.8.1"
},
@@ -4343,7 +4320,6 @@
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/trusted-types": "*"
}
@@ -4393,9 +4369,8 @@
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4864,7 +4839,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5142,7 +5116,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -5788,8 +5761,7 @@
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
- "license": "(MPL-2.0 OR Apache-2.0)",
- "peer": true
+ "license": "(MPL-2.0 OR Apache-2.0)"
},
"node_modules/domutils": {
"version": "3.2.2",
@@ -5861,7 +5833,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -5872,7 +5843,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6155,7 +6125,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7699,7 +7668,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -8057,7 +8025,6 @@
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cli-truncate": "^4.0.0",
"colorette": "^2.0.20",
@@ -8897,7 +8864,6 @@
"integrity": "sha512-yW5ME0hqTz38r/th/7zVwX5oSIw1FviSA2PUlGZdVjghDme/KX6iiwmOBmlt9E9whNmwijEC6Gn3KKbrsBx8ig==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@rollup/plugin-json": "^6.1.0",
@@ -10704,7 +10670,6 @@
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -10850,7 +10815,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -10874,7 +10838,6 @@
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -11791,8 +11754,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tslint": {
"version": "6.1.3",
@@ -12084,7 +12046,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12097,7 +12058,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/union": {
@@ -12245,7 +12206,6 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -12713,7 +12673,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -12732,8 +12691,7 @@
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
}
}
}
diff --git a/web/projects/setup-wizard/src/app/app.component.ts b/web/projects/setup-wizard/src/app/app.component.ts
index 847a6d584..b6bf0b3e5 100644
--- a/web/projects/setup-wizard/src/app/app.component.ts
+++ b/web/projects/setup-wizard/src/app/app.component.ts
@@ -31,6 +31,10 @@ export class AppComponent {
switch (status.status) {
case 'needs-install':
+ // Restore keyboard from status if it was previously set
+ if (status.keyboard) {
+ this.stateService.keyboard = status.keyboard.layout
+ }
// Start the install flow
await this.router.navigate(['/language'])
break
@@ -39,6 +43,10 @@ export class AppComponent {
// Store the data drive info from status
this.stateService.dataDriveGuid = status.guid
this.stateService.attach = status.attach
+ // Restore keyboard from status if it was previously set
+ if (status.keyboard) {
+ this.stateService.keyboard = status.keyboard.layout
+ }
await this.router.navigate(['/language'])
break
diff --git a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts
index 7ed0fda2c..ce343d7a6 100644
--- a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts
+++ b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts
@@ -1,13 +1,23 @@
-import { Component, inject } from '@angular/core'
+import { Component, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
-import { i18nPipe } from '@start9labs/shared'
+import {
+ getAllKeyboardsSorted,
+ i18nPipe,
+ Keyboard,
+ LanguageCode,
+} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
-import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
+import {
+ TuiButtonLoading,
+ TuiChevron,
+ TuiDataListWrapper,
+ TuiSelect,
+} from '@taiga-ui/kit'
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
+import { ApiService } from '../services/api.service'
import { StateService } from '../services/state.service'
-import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
@Component({
template: `
@@ -36,30 +46,22 @@ import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
`,
- styles: `
- :host {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100%;
- }
-
- footer {
- display: flex;
- justify-content: flex-end;
- margin-top: 1.5rem;
- }
- `,
imports: [
FormsModule,
TuiCardLarge,
TuiButton,
+ TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
@@ -71,24 +73,38 @@ import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
})
export default class KeyboardPage {
private readonly router = inject(Router)
+ private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
protected readonly mobile = inject(TUI_IS_MOBILE)
- readonly keyboards = getKeyboardsForLanguage(this.stateService.language)
+ // All keyboards, with language-specific keyboards at the top
+ readonly keyboards = getAllKeyboardsSorted(
+ this.stateService.language as LanguageCode,
+ )
selected =
this.keyboards.find(k => k.code === this.stateService.keyboard) ||
- this.keyboards[0]
+ this.keyboards[0]!
+
+ readonly saving = signal(false)
readonly stringify = (kb: Keyboard) => kb.name
- async back() {
- await this.router.navigate(['/language'])
- }
-
async continue() {
- if (this.selected) {
+ this.saving.set(true)
+
+ try {
+ // Send keyboard to backend
+ await this.api.setKeyboard({
+ layout: this.selected.code,
+ model: null,
+ variant: null,
+ options: [],
+ })
+
this.stateService.keyboard = this.selected.code
await this.navigateToNextStep()
+ } finally {
+ this.saving.set(false)
}
}
diff --git a/web/projects/setup-wizard/src/app/pages/language.page.ts b/web/projects/setup-wizard/src/app/pages/language.page.ts
index d7a5af24a..b14cc3d0b 100644
--- a/web/projects/setup-wizard/src/app/pages/language.page.ts
+++ b/web/projects/setup-wizard/src/app/pages/language.page.ts
@@ -1,18 +1,18 @@
-import { Component, inject } from '@angular/core'
+import { Component, computed, inject, signal } from '@angular/core'
import { Router } from '@angular/router'
import { FormsModule } from '@angular/forms'
-import { i18nPipe, i18nService } from '@start9labs/shared'
+import { i18nPipe, i18nService, Language, LANGUAGES } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
-import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
-import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
-import { StateService } from '../services/state.service'
import {
- LANGUAGES,
- Language,
- getDefaultKeyboard,
- needsKeyboardSelection,
-} from '../utils/languages'
+ TuiButtonLoading,
+ TuiChevron,
+ TuiDataListWrapper,
+ TuiSelect,
+} from '@taiga-ui/kit'
+import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
+import { ApiService } from '../services/api.service'
+import { StateService } from '../services/state.service'
@Component({
template: `
@@ -60,25 +60,23 @@ import {
@let lang = asLanguage(item);
{{ lang.nativeName }}
- {{ lang.tuiName | i18n }}
+ {{ lang.name | i18n }}
`,
styles: `
- :host {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100%;
- }
-
.language-item {
display: flex;
flex-direction: column;
@@ -92,6 +90,7 @@ import {
FormsModule,
TuiCardLarge,
TuiButton,
+ TuiButtonLoading,
TuiTextfield,
TuiChevron,
TuiSelect,
@@ -103,6 +102,7 @@ import {
})
export default class LanguagePage {
private readonly router = inject(Router)
+ private readonly api = inject(ApiService)
private readonly stateService = inject(StateService)
private readonly i18nService = inject(i18nService)
@@ -112,18 +112,23 @@ export default class LanguagePage {
selected =
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
+ private readonly saving = signal(false)
+
+ // Show loading when either language is loading or saving is in progress
+ readonly loading = computed(() => this.i18nService.loading() || this.saving())
+
readonly stringify = (lang: Language) => lang.nativeName
readonly asLanguage = (item: unknown): Language => item as Language
constructor() {
if (this.selected) {
- this.i18nService.setLanguage(this.selected.tuiName)
+ this.i18nService.setLanguage(this.selected.name)
}
}
onLanguageChange(language: Language) {
if (language) {
- this.i18nService.setLanguage(language.tuiName)
+ this.i18nService.setLanguage(language.name)
}
}
@@ -131,31 +136,16 @@ export default class LanguagePage {
if (this.selected) {
this.stateService.language = this.selected.code
- if (this.stateService.kiosk) {
- if (needsKeyboardSelection(this.selected.code)) {
- await this.router.navigate(['/keyboard'])
- } else {
- this.stateService.keyboard = getDefaultKeyboard(
- this.selected.code,
- ).code
- await this.navigateToNextStep()
- }
- } else {
- await this.navigateToNextStep()
- }
- }
- }
+ // Save language to backend
+ this.saving.set(true)
- private async navigateToNextStep() {
- if (this.stateService.dataDriveGuid) {
- if (this.stateService.attach) {
- this.stateService.setupType = 'attach'
- await this.router.navigate(['/password'])
- } else {
- await this.router.navigate(['/home'])
+ try {
+ await this.api.setLanguage({ language: this.selected.name })
+ // Always go to keyboard selection
+ await this.router.navigate(['/keyboard'])
+ } finally {
+ this.saving.set(false)
}
- } else {
- await this.router.navigate(['/drives'])
}
}
}
diff --git a/web/projects/setup-wizard/src/app/services/api.service.ts b/web/projects/setup-wizard/src/app/services/api.service.ts
index d92ff67ed..82ff96857 100644
--- a/web/projects/setup-wizard/src/app/services/api.service.ts
+++ b/web/projects/setup-wizard/src/app/services/api.service.ts
@@ -1,5 +1,11 @@
import * as jose from 'node-jose'
-import { DiskInfo, FollowLogsRes, StartOSDiskInfo } from '@start9labs/shared'
+import {
+ DiskInfo,
+ FollowLogsRes,
+ FullKeyboard,
+ SetLanguageParams,
+ StartOSDiskInfo,
+} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import {
@@ -21,6 +27,8 @@ export abstract class ApiService {
// Status & Setup
abstract getStatus(): Promise // setup.status
abstract getPubKey(): Promise // setup.get-pubkey
+ abstract setKeyboard(params: FullKeyboard): Promise // setup.set-keyboard
+ abstract setLanguage(params: SetLanguageParams): Promise // setup.set-language
// Install
abstract getDisks(): Promise // setup.disk.list
diff --git a/web/projects/setup-wizard/src/app/services/live-api.service.ts b/web/projects/setup-wizard/src/app/services/live-api.service.ts
index 149d16dde..1308433ec 100644
--- a/web/projects/setup-wizard/src/app/services/live-api.service.ts
+++ b/web/projects/setup-wizard/src/app/services/live-api.service.ts
@@ -3,10 +3,12 @@ import {
DiskInfo,
encodeBase64,
FollowLogsRes,
+ FullKeyboard,
HttpService,
isRpcError,
RpcError,
RPCOptions,
+ SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -64,6 +66,20 @@ export class LiveApiService extends ApiService {
this.pubkey = response
}
+ async setKeyboard(params: FullKeyboard): Promise {
+ return this.rpcRequest({
+ method: 'setup.set-keyboard',
+ params,
+ })
+ }
+
+ async setLanguage(params: SetLanguageParams): Promise {
+ return this.rpcRequest({
+ method: 'setup.set-language',
+ params,
+ })
+ }
+
async getDisks() {
return this.rpcRequest({
method: 'setup.disk.list',
diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts
index 3cc9b9216..47720229d 100644
--- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts
+++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts
@@ -3,7 +3,9 @@ import {
DiskInfo,
encodeBase64,
FollowLogsRes,
+ FullKeyboard,
pauseFor,
+ SetLanguageParams,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -68,8 +70,13 @@ export class MockApiService extends ApiService {
this.statusIndex++
if (this.statusIndex === 1) {
- // return { status: 'needs-install' }
- return { status: 'incomplete', attach: false, guid: 'mock-data-guid' }
+ // return { status: 'needs-install', keyboard: null }
+ return {
+ status: 'incomplete',
+ attach: false,
+ guid: 'mock-data-guid',
+ keyboard: null,
+ }
}
if (this.statusIndex > 3) {
@@ -93,6 +100,16 @@ export class MockApiService extends ApiService {
})
}
+ async setKeyboard(_params: FullKeyboard): Promise {
+ await pauseFor(300)
+ return null
+ }
+
+ async setLanguage(params: SetLanguageParams): Promise {
+ await pauseFor(300)
+ return null
+ }
+
async getDisks(): Promise {
await pauseFor(500)
return MOCK_DISKS
diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts
index 39e821ada..f0fe03a77 100644
--- a/web/projects/setup-wizard/src/app/services/state.service.ts
+++ b/web/projects/setup-wizard/src/app/services/state.service.ts
@@ -35,7 +35,7 @@ export class StateService {
// Set during install flow, or loaded from status response
language = ''
- keyboard = '' // only used if kiosk
+ keyboard = ''
// From install response or status response (incomplete)
dataDriveGuid = ''
@@ -52,8 +52,6 @@ export class StateService {
await this.api.attach({
guid: this.dataDriveGuid,
startOsPassword: password ? await this.api.encrypt(password) : null,
- language: this.language,
- kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
})
}
@@ -81,8 +79,6 @@ export class StateService {
await this.api.execute({
startOsLogicalname: this.dataDriveGuid,
startOsPassword: password ? await this.api.encrypt(password) : null,
- language: this.language,
- kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
recoverySource,
})
}
diff --git a/web/projects/setup-wizard/src/app/types.ts b/web/projects/setup-wizard/src/app/types.ts
index ea1d0facc..be5dcb3f5 100644
--- a/web/projects/setup-wizard/src/app/types.ts
+++ b/web/projects/setup-wizard/src/app/types.ts
@@ -1,4 +1,9 @@
-import { DiskInfo, PartitionInfo, StartOSDiskInfo } from '@start9labs/shared'
+import {
+ DiskInfo,
+ FullKeyboard,
+ PartitionInfo,
+ StartOSDiskInfo,
+} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
// === Echo ===
@@ -10,8 +15,13 @@ export type EchoReq = {
// === Setup Status ===
export type SetupStatusRes =
- | { status: 'needs-install' }
- | { status: 'incomplete'; guid: string; attach: boolean }
+ | { status: 'needs-install'; keyboard: FullKeyboard | null }
+ | {
+ status: 'incomplete'
+ guid: string
+ attach: boolean
+ keyboard: FullKeyboard | null
+ }
| { status: 'running'; progress: T.FullProgress; guid: string }
| { status: 'complete' }
@@ -35,8 +45,6 @@ export interface InstallOsRes {
export interface AttachParams {
startOsPassword: T.EncryptedWire | null
guid: string // data drive
- language: string
- kiosk: { keyboard: string } | null
}
// === Execute ===
@@ -44,8 +52,6 @@ export interface AttachParams {
export interface SetupExecuteParams {
startOsLogicalname: string
startOsPassword: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
- language: string
- kiosk: { keyboard: string } | null
recoverySource:
| {
type: 'migrate'
diff --git a/web/projects/setup-wizard/src/app/utils/languages.ts b/web/projects/setup-wizard/src/app/utils/languages.ts
deleted file mode 100644
index 034b0fb9b..000000000
--- a/web/projects/setup-wizard/src/app/utils/languages.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { i18nKey } from '@start9labs/shared'
-
-export interface Keyboard {
- code: string
- name: string
-}
-
-export interface Language {
- code: string
- tuiName: i18nKey
- nativeName: string
-}
-
-export const LANGUAGES: Language[] = [
- { code: 'en', tuiName: 'english', nativeName: 'English' },
- { code: 'es', tuiName: 'spanish', nativeName: 'Español' },
- { code: 'de', tuiName: 'german', nativeName: 'Deutsch' },
- { code: 'fr', tuiName: 'french', nativeName: 'Français' },
- { code: 'pl', tuiName: 'polish', nativeName: 'Polski' },
-]
-
-export const KEYBOARDS_BY_LANGUAGE: Record = {
- en: [
- { code: 'us', name: 'US English' },
- { code: 'gb', name: 'UK English' },
- ],
- es: [
- { code: 'es', name: 'Spanish' },
- { code: 'latam', name: 'Latin American' },
- ],
- de: [{ code: 'de', name: 'German' }],
- fr: [{ code: 'fr', name: 'French' }],
- pl: [{ code: 'pl', name: 'Polish' }],
-}
-
-/**
- * Get available keyboards for a language.
- * Returns array of keyboards (may be 1 or more).
- */
-export function getKeyboardsForLanguage(languageCode: string): Keyboard[] {
- return (
- KEYBOARDS_BY_LANGUAGE[languageCode] || [{ code: 'us', name: 'US English' }]
- )
-}
-
-/**
- * Check if keyboard selection is needed for a language.
- * Returns true if there are multiple keyboard options.
- */
-export function needsKeyboardSelection(languageCode: string): boolean {
- const keyboards = getKeyboardsForLanguage(languageCode)
- return keyboards.length > 1
-}
-
-/**
- * Get the default keyboard for a language.
- * Returns the first keyboard option.
- */
-export function getDefaultKeyboard(languageCode: string): Keyboard {
- return getKeyboardsForLanguage(languageCode)[0]!
-}
diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts
index 593e1e2d1..8d148b0d1 100644
--- a/web/projects/shared/src/i18n/dictionaries/de.ts
+++ b/web/projects/shared/src/i18n/dictionaries/de.ts
@@ -673,4 +673,6 @@ export default {
709: 'Laufwerk',
710: 'Übertragen',
711: 'Die Liste ist leer',
+ 712: 'Jetzt neu starten',
+ 713: 'Später',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts
index bed021c85..1aef6d5e3 100644
--- a/web/projects/shared/src/i18n/dictionaries/en.ts
+++ b/web/projects/shared/src/i18n/dictionaries/en.ts
@@ -673,4 +673,6 @@ export const ENGLISH = {
'Drive': 709, // as in, a storage device
'Transfer': 710, // the verb
'The list is empty': 711,
+ 'Restart now': 712,
+ 'Later': 713, // as in, (do it) later
} as const
diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts
index dbabf0512..825100b30 100644
--- a/web/projects/shared/src/i18n/dictionaries/es.ts
+++ b/web/projects/shared/src/i18n/dictionaries/es.ts
@@ -673,4 +673,6 @@ export default {
709: 'Unidad',
710: 'Transferir',
711: 'La lista está vacía',
+ 712: 'Reiniciar ahora',
+ 713: 'Más tarde',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts
index dc1b99b10..83fe4f543 100644
--- a/web/projects/shared/src/i18n/dictionaries/fr.ts
+++ b/web/projects/shared/src/i18n/dictionaries/fr.ts
@@ -673,4 +673,6 @@ export default {
709: 'Disque',
710: 'Transférer',
711: 'La liste est vide',
+ 712: 'Redémarrer maintenant',
+ 713: 'Plus tard',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts
index dd02ac4c2..53577081d 100644
--- a/web/projects/shared/src/i18n/dictionaries/pl.ts
+++ b/web/projects/shared/src/i18n/dictionaries/pl.ts
@@ -673,4 +673,6 @@ export default {
709: 'Dysk',
710: 'Przenieś',
711: 'Lista jest pusta',
+ 712: 'Uruchom ponownie teraz',
+ 713: 'Później',
} satisfies i18n
diff --git a/web/projects/shared/src/public-api.ts b/web/projects/shared/src/public-api.ts
index 3cd6a4dc3..3269be9aa 100644
--- a/web/projects/shared/src/public-api.ts
+++ b/web/projects/shared/src/public-api.ts
@@ -58,3 +58,5 @@ export * from './util/rpc.util'
export * from './util/to-guid'
export * from './util/to-local-iso-string'
export * from './util/unused'
+export * from './util/keyboards'
+export * from './util/languages'
diff --git a/web/projects/shared/src/util/keyboards.ts b/web/projects/shared/src/util/keyboards.ts
new file mode 100644
index 000000000..f281267c7
--- /dev/null
+++ b/web/projects/shared/src/util/keyboards.ts
@@ -0,0 +1,90 @@
+import { LanguageCode } from './languages'
+
+/**
+ * Keyboard layout codes
+ */
+export type KeyboardCode = 'us' | 'gb' | 'es' | 'latam' | 'de' | 'fr' | 'pl'
+
+/**
+ * Keyboard layout display names
+ */
+export type KeyboardName =
+ | 'US English'
+ | 'UK English'
+ | 'Spanish'
+ | 'Latin American'
+ | 'German'
+ | 'French'
+ | 'Polish'
+
+/**
+ * Keyboard layout definition
+ */
+export interface Keyboard {
+ code: KeyboardCode
+ name: KeyboardName
+}
+
+/**
+ * Full keyboard configuration for backend API
+ */
+export interface FullKeyboard {
+ layout: string
+ model: string | null
+ variant: string | null
+ options: string[]
+}
+
+/**
+ * Keyboard layouts grouped by language code
+ */
+export const KEYBOARDS_BY_LANGUAGE: Record = {
+ en: [
+ { code: 'us', name: 'US English' },
+ { code: 'gb', name: 'UK English' },
+ ],
+ es: [
+ { code: 'es', name: 'Spanish' },
+ { code: 'latam', name: 'Latin American' },
+ ],
+ de: [{ code: 'de', name: 'German' }],
+ fr: [{ code: 'fr', name: 'French' }],
+ pl: [{ code: 'pl', name: 'Polish' }],
+}
+
+/**
+ * All available keyboard layouts
+ */
+export const ALL_KEYBOARDS: Keyboard[] = [
+ { code: 'us', name: 'US English' },
+ { code: 'gb', name: 'UK English' },
+ { code: 'es', name: 'Spanish' },
+ { code: 'latam', name: 'Latin American' },
+ { code: 'de', name: 'German' },
+ { code: 'fr', name: 'French' },
+ { code: 'pl', name: 'Polish' },
+]
+
+/**
+ * Get all keyboards sorted with language-specific keyboards first,
+ * then remaining keyboards alphabetically by name.
+ */
+export function getAllKeyboardsSorted(languageCode: LanguageCode): Keyboard[] {
+ const languageKeyboards = KEYBOARDS_BY_LANGUAGE[languageCode]
+ const languageKeyboardCodes = new Set(languageKeyboards.map(kb => kb.code))
+ const otherKeyboards = ALL_KEYBOARDS.filter(
+ kb => !languageKeyboardCodes.has(kb.code),
+ ).sort((a, b) => a.name.localeCompare(b.name))
+ return [...languageKeyboards, ...otherKeyboards]
+}
+
+/**
+ * Get the display name for a keyboard code.
+ */
+export function getKeyboardName(
+ code: KeyboardCode | string,
+): KeyboardName | string {
+ const keyboard = ALL_KEYBOARDS.find(kb => kb.code === code)
+ if (keyboard) return keyboard.name
+ return code // fallback to the code itself if not found
+}
diff --git a/web/projects/shared/src/util/languages.ts b/web/projects/shared/src/util/languages.ts
new file mode 100644
index 000000000..b2b24c78a
--- /dev/null
+++ b/web/projects/shared/src/util/languages.ts
@@ -0,0 +1,44 @@
+import { Languages } from '../i18n/i18n.service'
+
+/**
+ * ISO language codes
+ */
+export type LanguageCode = 'en' | 'es' | 'de' | 'fr' | 'pl'
+
+/**
+ * Language definition with metadata
+ */
+export interface Language {
+ code: LanguageCode
+ name: Languages
+ nativeName: string
+}
+
+/**
+ * Available languages with their metadata
+ */
+export const LANGUAGES: Language[] = [
+ { code: 'en', name: 'english', nativeName: 'English' },
+ { code: 'es', name: 'spanish', nativeName: 'Español' },
+ { code: 'de', name: 'german', nativeName: 'Deutsch' },
+ { code: 'fr', name: 'french', nativeName: 'Français' },
+ { code: 'pl', name: 'polish', nativeName: 'Polski' },
+]
+
+/**
+ * Maps i18n language names to ISO language codes
+ */
+export const LANGUAGE_TO_CODE: Record = {
+ english: 'en',
+ spanish: 'es',
+ german: 'de',
+ french: 'fr',
+ polish: 'pl',
+}
+
+/**
+ * Params for setting language via API
+ */
+export interface SetLanguageParams {
+ language: Languages
+}
diff --git a/web/projects/ui/src/app/app.component.ts b/web/projects/ui/src/app/app.component.ts
index 0e9c25734..0c359da07 100644
--- a/web/projects/ui/src/app/app.component.ts
+++ b/web/projects/ui/src/app/app.component.ts
@@ -39,7 +39,7 @@ export class AppComponent {
.subscribe()
readonly ui = inject>(PatchDB)
- .watch$('ui', 'language')
+ .watch$('serverInfo', 'language')
.pipe(takeUntilDestroyed())
.subscribe(language => {
this.i18n.setLanguage(language || 'english')
diff --git a/web/projects/ui/src/app/app.providers.ts b/web/projects/ui/src/app/app.providers.ts
index e2db99464..edc8d0f4f 100644
--- a/web/projects/ui/src/app/app.providers.ts
+++ b/web/projects/ui/src/app/app.providers.ts
@@ -12,6 +12,7 @@ import {
I18N_PROVIDERS,
I18N_STORAGE,
i18nService,
+ Languages,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
@@ -128,7 +129,7 @@ export const APP_PROVIDERS = [
useFactory: () => {
const api = inject(ApiService)
- return (language: string) => api.setDbValue(['language'], language)
+ return (language: Languages) => api.setLanguage({ language })
},
},
{
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 14cc377fd..d77640356 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
@@ -12,11 +12,16 @@ import { RouterLink } from '@angular/router'
import {
DialogService,
ErrorService,
+ getAllKeyboardsSorted,
+ getKeyboardName,
i18nKey,
i18nPipe,
i18nService,
+ Keyboard,
+ KeyboardCode,
languages,
Languages,
+ LANGUAGE_TO_CODE,
LoadingService,
} from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
@@ -49,6 +54,7 @@ import { TitleDirective } from 'src/app/services/title.service'
import { SnekDirective } from './snek.directive'
import { UPDATE } from './update.component'
import { SystemWipeComponent } from './wipe.component'
+import { KeyboardSelectComponent } from './keyboard-select.component'
@Component({
template: `
@@ -134,20 +140,38 @@ import { SystemWipeComponent } from './wipe.component'
{{ 'Kiosk Mode' | i18n }}
-
+
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
-
- {{
- server.kiosk === true
- ? ('Disable Kiosk Mode unless you need to attach a monitor'
- | i18n)
- : server.kiosk === false
- ? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
- : ('Kiosk Mode is unavailable on this device' | i18n)
- }}
+
+ @if (server.kiosk === null) {
+ {{ 'Kiosk Mode is unavailable on this device' | i18n }}
+ } @else {
+ {{
+ server.kiosk
+ ? ('Disable Kiosk Mode unless you need to attach a monitor'
+ | i18n)
+ : ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
+ }}
+ }
+ @if (server.kiosk !== null && server.keyboard?.layout; as layout) {
+
+
+ {{ getKeyboardName(layout) }}
+
+
+ }
@if (server.kiosk !== null) {