Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions projects/start-os/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion projects/start-os/web/patchdb-ui-seed.beta.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
},
"startosRegistry": "https://beta-registry.start9.com/",
"snakeHighScore": 0,
"hiddenUpdates": {}
"hiddenUpdates": {},
"servicesView": {
"desktopLayout": "list",
"asc": true
}
}
6 changes: 5 additions & 1 deletion projects/start-os/web/patchdb-ui-seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
},
"startosRegistry": "https://registry.start9.com/",
"snakeHighScore": 0,
"hiddenUpdates": {}
"hiddenUpdates": {},
"servicesView": {
"desktopLayout": "list",
"asc": true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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: `
<ng-container *title>{{ 'Installed services' | i18n }}</ng-container>

<div #table [services]="services()"></div>
@if (services()?.length === 0) {
<app-placeholder>
<h1 [style.margin-bottom]="0">
{{ 'Welcome to' | i18n }}
<span>StartOS!</span>
</h1>

<p>
{{
'To get started, visit the Marketplace and download your first service'
| i18n
}}
</p>

<a
style="margin: 1.5rem 0;"
tuiButton
size="m"
iconStart="@tui.shopping-cart"
routerLink="../marketplace"
>
{{ 'View Marketplace' | i18n }}
</a>
</app-placeholder>
} @else {
<header>
@if (grid()) {
<button
appearance="secondary-grayscale"
size="s"
tuiIconButton
[iconStart]="
view().asc
? '@tui.arrow-up-narrow-wide'
: '@tui.arrow-down-wide-narrow'
"
(click)="update({ asc: !view().asc })"
>
{{ (view().asc ? 'Ascending' : 'Descending') | i18n }}
</button>
}
@if (!mobile()) {
<tui-segmented size="s" [activeItemIndex]="grid() ? 1 : 0">
<button
type="button"
[attr.aria-label]="'List view' | i18n"
[title]="'List view' | i18n"
(click)="update({ desktopLayout: 'list' })"
>
<tui-icon icon="@tui.list" />
</button>
<button
type="button"
[attr.aria-label]="'Grid view' | i18n"
[title]="'Grid view' | i18n"
(click)="update({ desktopLayout: 'grid' })"
>
<tui-icon icon="@tui.layout-grid" />
</button>
</tui-segmented>
}
</header>

@if (grid()) {
<div [asc]="view().asc" [servicesGrid]="services()"></div>
} @else {
<div [services]="services()"></div>
}
}
`,
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<DataModel>>(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<DataModel>>(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<unknown> = Promise.resolve()

protected readonly services = toSignal(
this.patch.watch$('packageData').pipe(
map(pkgs => Object.values(pkgs)),
shareReplay(1),
),
{ initialValue: null },
)

protected _ = viewChild<ServicesTableComponent<any>>('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<ServicesView>) {
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>(['servicesView'], next))
.catch(e => this.errorService.handleError(e))
}
}
Original file line number Diff line number Diff line change
@@ -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;

<a
[appServiceTile]="service"
[depErrors]="errors()?.[id] || {}"
[routerLink]="'/services/' + id"
></a>
} @empty {
@for (_ of '-'.repeat(6); track $index) {
<div tuiCardLarge="compact" [tuiSkeleton]="true">
<span tuiCell>
<span tuiAvatar></span>
<span tuiTitle>
{{ 'Loading' | i18n }}
<span tuiSubtitle>{{ 'Loading' | i18n }}</span>
</span>
</span>
</div>
}
}
`,
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<readonly PackageDataEntry[] | null>({
alias: 'servicesGrid',
})
readonly asc = input.required<boolean>()

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)
})
}
Loading