diff --git a/projects/start-os/CHANGELOG.md b/projects/start-os/CHANGELOG.md index 1b08373207..a1a9cb6599 100644 --- a/projects/start-os/CHANGELOG.md +++ b/projects/start-os/CHANGELOG.md @@ -65,6 +65,11 @@ file tracks notable changes since the move to the monorepo. service returns a URL — an authorization link, an admin panel — the result shows an open-in-new-tab button beside it. +- **Installed services can be shown as a grid of tiles.** A toggle at the top of + the page switches between the grid and the list, and your choice follows you to + any browser pointed at this server. Narrow windows and phones always show the + grid. + ### Changed - **Your server's name is now its `.local` address, without the `.local` on the diff --git a/projects/start-os/web/patchdb-ui-seed.beta.json b/projects/start-os/web/patchdb-ui-seed.beta.json index f29cc81766..5057a4f993 100644 --- a/projects/start-os/web/patchdb-ui-seed.beta.json +++ b/projects/start-os/web/patchdb-ui-seed.beta.json @@ -8,5 +8,9 @@ }, "startosRegistry": "https://beta-registry.start9.com/", "snakeHighScore": 0, - "hiddenUpdates": {} + "hiddenUpdates": {}, + "servicesView": { + "desktopLayout": "list", + "asc": true + } } diff --git a/projects/start-os/web/patchdb-ui-seed.json b/projects/start-os/web/patchdb-ui-seed.json index d28a115998..c690a12472 100644 --- a/projects/start-os/web/patchdb-ui-seed.json +++ b/projects/start-os/web/patchdb-ui-seed.json @@ -5,5 +5,9 @@ }, "startosRegistry": "https://registry.start9.com/", "snakeHighScore": 0, - "hiddenUpdates": {} + "hiddenUpdates": {}, + "servicesView": { + "desktopLayout": "list", + "asc": true + } } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/uptime.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/uptime.component.ts index 5c52d3013a..d44d87d21d 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/uptime.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/components/uptime.component.ts @@ -67,7 +67,8 @@ import { distinctUntilChanged } from 'rxjs/operators' } :host-context(table), - :host-context(service-status) { + :host-context(service-status), + :host-context(.service-tile) { padding: 0; header { @@ -100,13 +101,6 @@ import { distinctUntilChanged } from 'rxjs/operators' } } } - - :host-context(tui-root._mobile table) { - section { - min-height: 1.25rem; - align-items: flex-end; - } - } `, ], imports: [i18nPipe, AsyncPipe], diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts index f39f0ff1bd..0cee5b90a9 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts @@ -1,36 +1,153 @@ -import { Component, inject, viewChild } from '@angular/core' +import { Component, computed, inject, linkedSignal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { i18nPipe } from '@start9labs/shared' +import { RouterLink } from '@angular/router' +import { ErrorService, i18nPipe } from '@start9labs/shared' +import { TUI_BREAKPOINT, TuiButton, TuiIcon } from '@taiga-ui/core' +import { TuiSegmented } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' -import { map, shareReplay } from 'rxjs' -import { DataModel } from 'src/app/services/patch-db/data-model' +import { map, shareReplay, take } from 'rxjs' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { DataModel, ServicesView } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' +import { PlaceholderComponent } from '../../../components/placeholder.component' +import { ServicesGridComponent } from './grid.component' import { ServicesTableComponent } from './table.component' +const DEFAULT: ServicesView = { desktopLayout: 'list', asc: true } + @Component({ template: ` {{ 'Installed services' | i18n }} -
+ @if (services()?.length === 0) { + +

+ {{ 'Welcome to' | i18n }} + StartOS! +

+ +

+ {{ + 'To get started, visit the Marketplace and download your first service' + | i18n + }} +

+ + + {{ 'View Marketplace' | i18n }} + +
+ } @else { +
+ @if (grid()) { + + } + @if (!mobile()) { + + + + + } +
+ + @if (grid()) { +
+ } @else { +
+ } + } `, styles: ` :host { padding: 1rem; } + + header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + margin-block-end: 1rem; + } `, host: { class: 'g-page' }, - imports: [TitleDirective, i18nPipe, ServicesTableComponent], + imports: [ + PlaceholderComponent, + RouterLink, + ServicesGridComponent, + ServicesTableComponent, + TitleDirective, + TuiButton, + TuiIcon, + TuiSegmented, + i18nPipe, + ], }) export default class DashboardComponent { - readonly services = toSignal( - inject>(PatchDB) - .watch$('packageData') - .pipe( - map(pkgs => Object.values(pkgs)), - shareReplay(1), - ), + private readonly api = inject(ApiService) + private readonly breakpoint = inject(TUI_BREAKPOINT) + private readonly errorService = inject(ErrorService) + private readonly patch = inject>(PatchDB) + + // Seeded once: a later echo of our own write would clobber a newer local change. + private readonly persisted = toSignal( + this.patch.watch$('ui', 'servicesView').pipe(take(1)), + ) + private write: Promise = Promise.resolve() + + protected readonly services = toSignal( + this.patch.watch$('packageData').pipe( + map(pkgs => Object.values(pkgs)), + shareReplay(1), + ), { initialValue: null }, ) - protected _ = viewChild>('table') + protected readonly mobile = computed(() => this.breakpoint() === 'mobile') + protected readonly view = linkedSignal(() => this.persisted() || DEFAULT) + protected readonly grid = computed( + () => this.mobile() || this.view().desktopLayout === 'grid', + ) + + protected update(patch: Partial) { + const next = { ...this.view(), ...patch } + + this.view.set(next) + // Chained so rapid clicks reach the server in click order and the last wins. + this.write = this.write + .then(() => this.api.setDbValue(['servicesView'], next)) + .catch(e => this.errorService.handleError(e)) + } } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/grid.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/grid.component.ts new file mode 100644 index 0000000000..01c65ee16a --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/grid.component.ts @@ -0,0 +1,72 @@ +import { Component, computed, inject, input } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { RouterLink } from '@angular/router' +import { i18nPipe } from '@start9labs/shared' +import { TuiCell, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar, TuiSkeleton } from '@taiga-ui/kit' +import { TuiCardLarge } from '@taiga-ui/layout' +import { DepErrorService } from 'src/app/services/dep-error.service' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' +import { getManifest } from 'src/app/utils/get-package-data' +import { byName } from './sorters' +import { ServiceTileComponent } from './tile.component' + +@Component({ + selector: '[servicesGrid]', + template: ` + @for (service of sorted(); track manifest(service).id) { + @let id = manifest(service).id; + + + } @empty { + @for (_ of '-'.repeat(6); track $index) { +
+ + + + {{ 'Loading' | i18n }} + {{ 'Loading' | i18n }} + + +
+ } + } + `, + styles: ` + :host { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + } + `, + imports: [ + RouterLink, + ServiceTileComponent, + TuiAvatar, + TuiCardLarge, + TuiCell, + TuiSkeleton, + TuiTitle, + i18nPipe, + ], +}) +export class ServicesGridComponent { + private readonly depErrors = inject(DepErrorService) + + readonly services = input.required({ + alias: 'servicesGrid', + }) + readonly asc = input.required() + + protected readonly errors = toSignal(this.depErrors.depErrors$) + protected readonly manifest = getManifest + protected readonly sorted = computed(() => { + const direction = this.asc() ? 1 : -1 + + return [...(this.services() || [])].sort((a, b) => byName(a, b) * direction) + }) +} diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts index d922fafb01..36ba96b828 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/service.component.ts @@ -1,6 +1,5 @@ import { Component, computed, input } from '@angular/core' import { RouterLink } from '@angular/router' -import { i18nPipe } from '@start9labs/shared' import { TuiAvatar } from '@taiga-ui/kit' import { ServiceUptimeComponent } from 'src/app/routes/portal/routes/services/components/uptime.component' import { PkgDependencyErrors } from 'src/app/services/dep-error.service' @@ -11,7 +10,7 @@ import { StatusComponent } from './status.component' @Component({ selector: 'tr[appService]', template: ` - + logo @@ -19,13 +18,12 @@ import { StatusComponent } from './status.component' {{ manifest().title }} - + {{ manifest().version }} @if (pkg().statusInfo.started; as started) { - {{ 'Uptime' | i18n }}: } @else { - @@ -53,10 +51,6 @@ import { StatusComponent } from './status.component' font-weight: bold; } - span { - display: none; - } - .title { width: 21rem; } @@ -72,72 +66,8 @@ import { StatusComponent } from './status.component' .version { width: 21rem; } - - :host-context(tui-root._mobile) { - position: relative; - display: grid; - grid-template: 1.25rem 2rem 1.5rem/4rem 1fr; - align-items: center; - padding: 1rem; - - &:hover { - background: none; - } - - [tuiAvatar] { - height: 2.5rem; - width: 2.5rem; - } - - td { - padding: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--tui-text-secondary); - - &::before { - display: inline; - } - - &:empty { - display: none; - } - } - - .title { - grid-area: 2 / 2; - font: var(--tui-typography-heading-h6); - } - - .version { - grid-area: 1 / 2; - font: var(--tui-typography-body-s); - } - - .uptime { - grid-area: 4 / 2; - display: flex; - align-items: baseline; - gap: 0.5rem; - - &:not(:has(service-uptime)) { - display: none; - } - - span { - display: inline-block; - } - } - } `, - imports: [ - RouterLink, - StatusComponent, - ServiceUptimeComponent, - i18nPipe, - TuiAvatar, - ], + imports: [RouterLink, ServiceUptimeComponent, StatusComponent, TuiAvatar], }) export class ServiceComponent { readonly pkg = input.required() diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts new file mode 100644 index 0000000000..171a56d5ec --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/sorters.ts @@ -0,0 +1,12 @@ +import { TuiComparator } from '@taiga-ui/addon-table' +import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' +import { getManifest } from 'src/app/utils/get-package-data' + +export const byName: TuiComparator = (a, b) => + getManifest(b).title.toLowerCase() > getManifest(a).title.toLowerCase() + ? -1 + : 1 + +export const byStatus: TuiComparator = (a, b) => + getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1 diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts index c61889fefb..da282015e0 100644 --- a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts @@ -1,106 +1,60 @@ import { Component, inject, input } from '@angular/core' -import { FormsModule } from '@angular/forms' -import { TableComponent } from 'src/app/routes/portal/components/table.component' -import { T } from '@start9labs/start-core' -import { - PackageDataEntry, - StateInfo, -} from 'src/app/services/patch-db/data-model' -import { ServiceComponent } from './service.component' -import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' -import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service' -import { getManifest } from 'src/app/utils/get-package-data' -import { ToManifestPipe } from '../../../pipes/to-manifest' import { toSignal } from '@angular/core/rxjs-interop' -import { DepErrorService } from 'src/app/services/dep-error.service' +import { FormsModule } from '@angular/forms' +import { RouterLink } from '@angular/router' import { i18nPipe } from '@start9labs/shared' +import { TuiTable } from '@taiga-ui/addon-table' import { TuiSkeleton } from '@taiga-ui/kit' -import { PlaceholderComponent } from '../../../components/placeholder.component' -import { TuiButton } from '@taiga-ui/core' -import { RouterLink } from '@angular/router' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { DepErrorService } from 'src/app/services/dep-error.service' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' +import { ToManifestPipe } from '../../../pipes/to-manifest' +import { ServiceComponent } from './service.component' +import { byName, byStatus } from './sorters' @Component({ selector: '[services]', template: ` - @if (services()?.length === 0) { - -

- {{ 'Welcome to' | i18n }} - StartOS! -

- -

- {{ - 'To get started, visit the Marketplace and download your first service' - | i18n - }} -

- - - {{ 'View Marketplace' | i18n }} - -
- } @else { - - @for (service of services() | tuiTableSort; track $index) { - - } @empty { - @for (_ of ['', '']; track $index) { - - - - } +
-
{{ 'Loading' | i18n }}
-
+ @for (service of services() | tuiTableSort; track $index) { + + } @empty { + @for (_ of ['', '']; track $index) { + + + } -
+
{{ 'Loading' | i18n }}
+
- } + } + `, imports: [ FormsModule, - TableComponent, + RouterLink, ServiceComponent, + TableComponent, ToManifestPipe, - i18nPipe, TuiSkeleton, - PlaceholderComponent, - TuiButton, - RouterLink, TuiTable, + i18nPipe, ], }) -export class ServicesTableComponent< - T extends T.PackageDataEntry & { - stateInfo: StateInfo - }, -> { - readonly errors = toSignal(inject(DepErrorService).depErrors$) - - readonly services = input.required() +export class ServicesTableComponent { + private readonly depErrors = inject(DepErrorService) - readonly name: TuiComparator = byName - - readonly status: TuiComparator = (a, b) => - getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1 -} + readonly services = input.required() -function byName(a: PackageDataEntry, b: PackageDataEntry) { - return getManifest(b).title.toLowerCase() > getManifest(a).title.toLowerCase() - ? -1 - : 1 + protected readonly errors = toSignal(this.depErrors.depErrors$) + protected readonly byName = byName + protected readonly byStatus = byStatus } diff --git a/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts new file mode 100644 index 0000000000..32cf6d4de6 --- /dev/null +++ b/projects/start-os/web/ui/src/app/routes/portal/routes/services/dashboard/tile.component.ts @@ -0,0 +1,70 @@ +import { Component, computed, input } from '@angular/core' +import { TuiCell, TuiTitle } from '@taiga-ui/core' +import { TuiAvatar } from '@taiga-ui/kit' +import { TuiCardLarge, tuiCardOptionsProvider } from '@taiga-ui/layout' +import { ServiceUptimeComponent } from 'src/app/routes/portal/routes/services/components/uptime.component' +import { PkgDependencyErrors } from 'src/app/services/dep-error.service' +import { PackageDataEntry } from 'src/app/services/patch-db/data-model' +import { getManifest } from 'src/app/utils/get-package-data' +import { StatusComponent } from './status.component' + +@Component({ + selector: 'a[appServiceTile]', + template: ` + + + + + + {{ manifest().title }} + {{ manifest().version }} + + + + + @if (pkg().statusInfo.started; as started) { + + } + + `, + styles: ` + :host { + // "floating" paints elevation-1, which is the g-page ground itself. + --tui-background-elevation-1: color-mix( + in hsl, + var(--start9-base-2) 75%, + transparent + ); + + color: var(--tui-text-primary); + } + + .status { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--tui-text-secondary); + } + `, + host: { class: 'service-tile' }, + hostDirectives: [TuiCardLarge], + providers: [ + tuiCardOptionsProvider({ space: 'compact', appearance: 'floating' }), + ], + imports: [ + ServiceUptimeComponent, + StatusComponent, + TuiAvatar, + TuiCell, + TuiTitle, + ], +}) +export class ServiceTileComponent { + readonly pkg = input.required({ alias: 'appServiceTile' }) + readonly depErrors = input({}) + + protected readonly manifest = computed(() => getManifest(this.pkg())) + protected readonly hasError = computed(() => + Object.values(this.depErrors()).some(Boolean), + ) +} diff --git a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts index 26014ad13f..55845e40c0 100644 --- a/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/projects/start-os/web/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from '@angular/core' +import { WA_SESSION_STORAGE } from '@ng-web-apis/common' import { GetPackageRes, GetPackagesRes } from '@start9labs/marketplace' import { FullKeyboard, @@ -9,6 +10,7 @@ import { import { T } from '@start9labs/start-core' import { AddOperation, + applyOperation, Dump, Operation, PatchOp, @@ -24,6 +26,7 @@ import { InstallingState, PackageDataEntry, StateInfo, + UIData, UpdatingState, } from 'src/app/services/patch-db/data-model' import { toAuthorityUrl } from 'src/app/utils/acme' @@ -56,6 +59,8 @@ import { mockPatchData } from './mock-patch' import markdown from './md-sample.md' +const UI_KEY = '_startos/mock-ui' + const PROGRESS: T.FullProgress = { overall: { done: 0, @@ -100,6 +105,7 @@ const INIT_PROGRESS: T.FullProgress = { @Injectable() export class MockApiService extends ApiService { readonly mockWsSource$ = new Subject() + private readonly storage = inject(WA_SESSION_STORAGE) private readonly revertTime = 1800 sequence = 0 @@ -187,6 +193,13 @@ export class MockApiService extends ApiService { guid: string }> { await pauseFor(2000) + + const stored = this.storage?.getItem(UI_KEY) + + if (stored) { + mockPatchData.ui = { ...mockPatchData.ui, ...JSON.parse(stored) } + } + return { dump: { id: 1, value: mockPatchData }, guid: 'db-guid', @@ -198,16 +211,14 @@ export class MockApiService extends ApiService { value: T, ): Promise { const pointer = pathFromArray(pathArr) - const params = { pointer, value } await pauseFor(2000) - const patch = [ - { - op: PatchOp.REPLACE, - path: '/ui' + params.pointer, - value: params.value, - }, - ] - this.mockRevision(patch) + + const ui: Dump = { id: 0, value: mockPatchData.ui } + + applyOperation(ui, { op: PatchOp.REPLACE, path: pointer, value }) + mockPatchData.ui = ui.value + this.storage?.setItem(UI_KEY, JSON.stringify(ui.value)) + this.mockRevision([{ op: PatchOp.REPLACE, path: '/ui' + pointer, value }]) return null } diff --git a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts index e398e5798c..85a823e00e 100644 --- a/projects/start-os/web/ui/src/app/services/api/mock-patch.ts +++ b/projects/start-os/web/ui/src/app/services/api/mock-patch.ts @@ -1,8 +1,45 @@ -import { DataModel } from 'src/app/services/patch-db/data-model' +import { T } from '@start9labs/start-core' +import { + DataModel, + InstalledState, + PackageDataEntry, +} from 'src/app/services/patch-db/data-model' import { knownAuthorities } from 'src/app/utils/acme' import { Mock } from './api.fixures' const version = require('../../../../../../../../package.json').version +function mockService( + id: string, + title: string, + pkgVersion: string, + statusInfo: T.StatusInfo, +): PackageDataEntry { + return { + stateInfo: { + state: 'installed', + manifest: { + ...Mock.MockManifestBitcoind, + id, + title, + version: pkgVersion, + }, + }, + s9pk: `/media/startos/data/package-data/archive/installed/${id}.s9pk`, + icon: '/assets/img/service-icons/fallback.png', + lastBackup: null, + statusInfo, + actions: {}, + currentDependencies: {}, + hosts: {}, + storeExposedDependents: [], + outboundGateway: null, + registry: 'https://registry.start9.com/', + developerKey: 'developer-key', + plugin: { url: null }, + tasks: {}, + } +} + export const mockPatchData: DataModel = { ui: { registries: { @@ -12,6 +49,7 @@ export const mockPatchData: DataModel = { startosRegistry: 'https://registry.start9.com/', snakeHighScore: 0, hiddenUpdates: {}, + servicesView: { desktopLayout: 'list', asc: true }, }, serverInfo: { id: 'abcdefgh', @@ -973,5 +1011,54 @@ export const mockPatchData: DataModel = { plugin: { url: { tableAction: 'create-onion-service' } }, tasks: {}, }, + nextcloud: mockService('nextcloud', 'Nextcloud', '30.0.2:0', { + desired: { main: 'running' }, + error: null, + health: { + web: { name: 'Web Interface', result: 'success', message: null }, + }, + started: new Date(Date.now() - 86400000).toISOString(), + }), + vaultwarden: mockService('vaultwarden', 'Vaultwarden', '1.32.7:0', { + desired: { main: 'running' }, + error: null, + health: { + web: { name: 'Web Vault', result: 'loading', message: 'Starting up' }, + }, + started: new Date(Date.now() - 30000).toISOString(), + }), + jellyfin: mockService('jellyfin', 'Jellyfin', '10.10.3:0', { + desired: { main: 'stopped' }, + error: null, + health: {}, + started: null, + }), + electrs: mockService('electrs', 'Electrs', '0.10.5:0', { + desired: { main: 'running' }, + error: null, + health: { + rpc: { + name: 'RPC', + result: 'failure', + message: 'Cannot reach bitcoind', + }, + }, + started: new Date(Date.now() - 3600000).toISOString(), + }), + 'home-assistant': mockService( + 'home-assistant', + 'Home Assistant', + '2024.11.3:0', + { + desired: { main: 'running' }, + error: { + details: 'Container exited unexpectedly', + debug: 'exit code 137', + info: null, + }, + health: {}, + started: null, + }, + ), }, } diff --git a/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts b/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts index c4a32c848d..ecebecb483 100644 --- a/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts +++ b/projects/start-os/web/ui/src/app/services/patch-db/data-model.ts @@ -15,6 +15,14 @@ export type UIData = { snakeHighScore: number startosRegistry: string hiddenUpdates: Record + // Absent on upgraded servers — the seed only applies at first boot. + servicesView?: ServicesView +} + +export type ServicesView = { + // Narrow viewports always render the grid, so the layout is desktop-only. + desktopLayout: 'list' | 'grid' + asc: boolean } export type PackageDataEntry = diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts index ce2d3e37bf..6b4c0cdc28 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/de.ts @@ -816,4 +816,8 @@ export default { 923: 'Die lokale Adresse jedes installierten Dienstes', 924: 'Der Browser-Tab und der Name der installierten App', 925: 'SSH und Systemprotokolle', + 926: 'Listenansicht', + 927: 'Rasteransicht', + 928: 'Aufsteigend', + 929: 'Absteigend', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts index 923b1d95a5..239dbabd8d 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/en.ts @@ -817,4 +817,8 @@ export const ENGLISH: Record = { 'The local address of every installed service': 923, 'The browser tab and the name of the installed app': 924, 'SSH and system logs': 925, + 'List view': 926, + 'Grid view': 927, + 'Ascending': 928, + 'Descending': 929, } diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts index fb11530f4e..1a97821fa7 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/es.ts @@ -816,4 +816,8 @@ export default { 923: 'La dirección local de cada servicio instalado', 924: 'La pestaña del navegador y el nombre de la aplicación instalada', 925: 'SSH y los registros del sistema', + 926: 'Vista de lista', + 927: 'Vista de cuadrícula', + 928: 'Ascendente', + 929: 'Descendente', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts index bb0b85278f..54113ed75b 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/fr.ts @@ -816,4 +816,8 @@ export default { 923: 'L’adresse locale de chaque service installé', 924: 'L’onglet du navigateur et le nom de l’application installée', 925: 'SSH et les journaux système', + 926: 'Vue liste', + 927: 'Vue grille', + 928: 'Croissant', + 929: 'Décroissant', } satisfies i18n diff --git a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts index 44f0f38275..c81c67da01 100644 --- a/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts +++ b/shared-libs/ts-modules/shared/src/i18n/dictionaries/pl.ts @@ -816,4 +816,8 @@ export default { 923: 'Lokalny adres każdej zainstalowanej usługi', 924: 'Karta przeglądarki i nazwa zainstalowanej aplikacji', 925: 'SSH i dzienniki systemowe', + 926: 'Widok listy', + 927: 'Widok siatki', + 928: 'Rosnąco', + 929: 'Malejąco', } satisfies i18n