feat(portal): implement adding/removing to desktop (#2374)

* feat(portal): implement adding/removing to desktop, reordering desktop items, baseline for system utils

* chore: fix comments

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
This commit is contained in:
Alex Inkin
2023-07-27 22:51:15 +04:00
committed by GitHub
parent a5307fd8cc
commit 9f5a90ee9c
33 changed files with 462 additions and 232 deletions

View File

@@ -23,11 +23,11 @@
"@start9labs/argon2": "^0.1.0", "@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5", "@start9labs/emver": "^0.1.5",
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc5", "@start9labs/start-sdk": "0.4.0-rev0.lib0.rc5",
"@taiga-ui/addon-charts": "3.36.0", "@taiga-ui/addon-charts": "3.38.0",
"@taiga-ui/cdk": "3.36.0", "@taiga-ui/cdk": "3.38.0",
"@taiga-ui/core": "3.36.0", "@taiga-ui/core": "3.38.0",
"@taiga-ui/icons": "3.36.0", "@taiga-ui/icons": "3.38.0",
"@taiga-ui/kit": "3.36.0", "@taiga-ui/kit": "3.38.0",
"@tinkoff/ng-dompurify": "4.0.0", "@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
@@ -3553,9 +3553,9 @@
"dev": true "dev": true
}, },
"node_modules/@maskito/angular": { "node_modules/@maskito/angular": {
"version": "1.2.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-1.3.0.tgz",
"integrity": "sha512-2YD/MWxESVn5/nckZj4F3GArzxjN3M4V8SHhtxI4c3wtg1m8ewoO8r7o3HYk/4aVLxxR0y2bz6cOWJtawt4KoQ==", "integrity": "sha512-SAuhTl3OkZ1Ff9TAksO+yLHgsv8N4LZTVOaFLyeYUQyLH/8nNcKTDMU/w1pRhoS0+7sXHH6/YzQ4CEHLgguHRA==",
"dependencies": { "dependencies": {
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@@ -3563,21 +3563,21 @@
"@angular/common": ">=12.0.0", "@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0", "@angular/core": ">=12.0.0",
"@angular/forms": ">=12.0.0", "@angular/forms": ">=12.0.0",
"@maskito/core": "^1.2.0", "@maskito/core": "^1.3.0",
"rxjs": ">=6.0.0" "rxjs": ">=6.0.0"
} }
}, },
"node_modules/@maskito/core": { "node_modules/@maskito/core": {
"version": "1.2.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@maskito/core/-/core-1.3.0.tgz",
"integrity": "sha512-RFSydWYujxbVBbMzQVZ0zR77ROY3MbcuyKFWLomJWw3rDujl65M2ppz5KMeDSogAGkKnqzWudozjmBAQf2DgcA==" "integrity": "sha512-JFSUHJw+dB7yFzaX45S+t4ivPznOlsAqRorgGr4Gx3CR0DU8CZhZsSVCIeSNABsrIgtHPtlhiAv3Jw6EaqShTg=="
}, },
"node_modules/@maskito/kit": { "node_modules/@maskito/kit": {
"version": "1.2.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-1.3.0.tgz",
"integrity": "sha512-sMUZ3vMp3RCAcw+H/TuxyrJDgz6J5TTUCc+2/inTCE1gr33FsmhzLqoi5PaYrD146VcOKdtAxd3NJ1RK/g1ZHw==", "integrity": "sha512-DwYIEE7+fh/6q05KTzPEs+qnJp8jsXQa6h9UBk2Zlnp97PerPO56HGhvm2kAm/LSYtDzTCeurrvCAqncUSSOIg==",
"peerDependencies": { "peerDependencies": {
"@maskito/core": "^1.2.0" "@maskito/core": "^1.3.0"
} }
}, },
"node_modules/@materia-ui/ngx-monaco-editor": { "node_modules/@materia-ui/ngx-monaco-editor": {
@@ -3998,9 +3998,9 @@
} }
}, },
"node_modules/@taiga-ui/addon-charts": { "node_modules/@taiga-ui/addon-charts": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.38.0.tgz",
"integrity": "sha512-GZqhXUNNBtjX0jqPuYtYLjALTP0boV3cORnYt9/pXZ1DSXje6AyjLAmYXY/u7vlgcWAggLPd6A1GXszSOBDdIA==", "integrity": "sha512-3/8M/FTKZ7OU1CdTInHrNSueQrHPqlas7+gvkj6jKCHuhqqe5MsBWYBIh8jywvbI6lbMGhXoNXYqrVzpvX2YNA==",
"dependencies": { "dependencies": {
"tslib": ">=2.0.0" "tslib": ">=2.0.0"
}, },
@@ -4008,15 +4008,15 @@
"@angular/common": ">=12.0.0", "@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0", "@angular/core": ">=12.0.0",
"@ng-web-apis/common": ">=3.0.0", "@ng-web-apis/common": ">=3.0.0",
"@taiga-ui/cdk": ">=3.36.0", "@taiga-ui/cdk": ">=3.38.0",
"@taiga-ui/core": ">=3.36.0", "@taiga-ui/core": ">=3.38.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0" "@tinkoff/ng-polymorpheus": ">=4.0.0"
} }
}, },
"node_modules/@taiga-ui/cdk": { "node_modules/@taiga-ui/cdk": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.38.0.tgz",
"integrity": "sha512-ipoL6/P8OqsVXTcP1kXP5qeQ4Dtno6893xioHmke+SQpoOYO7u9JUZgj9exdL8Zyy4SdXF456EzB9qib79GN6g==", "integrity": "sha512-932i9DTnCJN4KlUDazVet+30C/iUnCX5ldrC5nJMglbn42/4/lW1Rlh8RNHhXlL61iz8+FqGkMSE+YAKhKKl0w==",
"dependencies": { "dependencies": {
"@ng-web-apis/common": "3.0.1", "@ng-web-apis/common": "3.0.1",
"@ng-web-apis/mutation-observer": "3.0.1", "@ng-web-apis/mutation-observer": "3.0.1",
@@ -4038,11 +4038,11 @@
} }
}, },
"node_modules/@taiga-ui/core": { "node_modules/@taiga-ui/core": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.38.0.tgz",
"integrity": "sha512-X1l9kQLdVkN5oVNHgiFtKmCtPOneOtgI8SdPFgrhlTdNI9ve3cy4vhLWtVq441QYnTM/MIDPsTNXgRend/dDsg==", "integrity": "sha512-7j5u15d5J8iOEVQY/xUGSvmoHkgRbBrzner6kCj4ZSsgP7Mu+yamDQmSJdRzORqpG/oOBwtjuXZTr9ic8NWEXQ==",
"dependencies": { "dependencies": {
"@taiga-ui/i18n": "^3.36.0", "@taiga-ui/i18n": "^3.38.0",
"tslib": ">=2.0.0" "tslib": ">=2.0.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -4054,41 +4054,44 @@
"@angular/router": ">=12.0.0", "@angular/router": ">=12.0.0",
"@ng-web-apis/common": ">=3.0.0", "@ng-web-apis/common": ">=3.0.0",
"@ng-web-apis/mutation-observer": ">=3.0.0", "@ng-web-apis/mutation-observer": ">=3.0.0",
"@taiga-ui/cdk": ">=3.36.0", "@taiga-ui/cdk": ">=3.38.0",
"@taiga-ui/i18n": ">=3.36.0", "@taiga-ui/i18n": ">=3.38.0",
"@tinkoff/ng-event-plugins": ">=3.1.0", "@tinkoff/ng-event-plugins": ">=3.1.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0", "@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0" "rxjs": ">=6.0.0"
} }
}, },
"node_modules/@taiga-ui/i18n": { "node_modules/@taiga-ui/i18n": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.38.0.tgz",
"integrity": "sha512-vl7rXDYR0LvDJrOimN+wR+7bZww7Cv1JxwsZpbrt5hxXhX5Ih36bMtBqJMEfziCL2XOuFbor2KjegllXreEHPA==", "integrity": "sha512-jejqnDjLHbm23sZ0ypRoy7bWrL9W57ISH74ArRNa1fV0Z+H0oHlkgz7JxDwEF8qmOOdZoYOAIkgZLRCEs3Cz+w==",
"dependencies": { "dependencies": {
"tslib": ">=2.0.0" "tslib": ">=2.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": ">=12.0.0", "@angular/core": ">=12.0.0",
"rxjs": ">=6.0.0" "@taiga-ui/cdk": ">=3.38.0"
} }
}, },
"node_modules/@taiga-ui/icons": { "node_modules/@taiga-ui/icons": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.38.0.tgz",
"integrity": "sha512-naXB46KRDfxYFxKllrpexy/+zQ1ki3IkhBfHhoFhi0WuSW3pZ2GV8kDpFI6B49FDHMQTM2FcZ2oHAC5HEGKjKw==", "integrity": "sha512-SRhcQaNG08a+MbISCMXBvu79mHrl7H7MCUSoP3fMy8Y3yyJqE0cchnaYZosijrEFR9mRzn0JrQ75Hpo1FaJf5w==",
"dependencies": { "dependencies": {
"tslib": "^2.2.0" "tslib": ">=2.0.0"
},
"peerDependencies": {
"@taiga-ui/cdk": ">=3.38.0"
} }
}, },
"node_modules/@taiga-ui/kit": { "node_modules/@taiga-ui/kit": {
"version": "3.36.0", "version": "3.38.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.36.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.38.0.tgz",
"integrity": "sha512-8aTKchdKmUfb6ud0iFsVnhQRg+d1zCla0coV+7n0GaHkfPd4Pp5DGiYaJMs6p9rixZM4sUyvRtYxO6p2bKaPQQ==", "integrity": "sha512-CdsYhxNhiQfQPfxbAtbZYEcKR1VbyVvtFG071Ry8/DwhjyaC6BkYyyARJqjjxT6cn2gNmb8njbcuuENqGf/ZXw==",
"dependencies": { "dependencies": {
"@maskito/angular": "1.2.0", "@maskito/angular": "1.3.0",
"@maskito/core": "1.2.0", "@maskito/core": "1.3.0",
"@maskito/kit": "1.2.0", "@maskito/kit": "1.3.0",
"@ng-web-apis/intersection-observer": "3.1.1", "@ng-web-apis/intersection-observer": "3.1.1",
"text-mask-core": "5.1.2", "text-mask-core": "5.1.2",
"tslib": ">=2.0.0" "tslib": ">=2.0.0"
@@ -4101,9 +4104,9 @@
"@ng-web-apis/common": ">=3.0.0", "@ng-web-apis/common": ">=3.0.0",
"@ng-web-apis/mutation-observer": ">=3.0.0", "@ng-web-apis/mutation-observer": ">=3.0.0",
"@ng-web-apis/resize-observer": ">=3.0.0", "@ng-web-apis/resize-observer": ">=3.0.0",
"@taiga-ui/cdk": ">=3.36.0", "@taiga-ui/cdk": ">=3.38.0",
"@taiga-ui/core": ">=3.36.0", "@taiga-ui/core": ">=3.38.0",
"@taiga-ui/i18n": ">=3.36.0", "@taiga-ui/i18n": ">=3.38.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0", "@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0" "rxjs": ">=6.0.0"
} }

View File

@@ -44,11 +44,11 @@
"@materia-ui/ngx-monaco-editor": "^6.0.0", "@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0", "@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5", "@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.36.0", "@taiga-ui/addon-charts": "3.38.0",
"@taiga-ui/cdk": "3.36.0", "@taiga-ui/cdk": "3.38.0",
"@taiga-ui/core": "3.36.0", "@taiga-ui/core": "3.38.0",
"@taiga-ui/icons": "3.36.0", "@taiga-ui/icons": "3.38.0",
"@taiga-ui/kit": "3.36.0", "@taiga-ui/kit": "3.38.0",
"@tinkoff/ng-dompurify": "4.0.0", "@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",

View File

@@ -1,22 +0,0 @@
import { Directive } from '@angular/core'
import {
AbstractTuiDialogDirective,
AbstractTuiDialogService,
} from '@taiga-ui/cdk'
import { TuiAlertOptions, TuiAlertService } from '@taiga-ui/core'
// TODO: Move to Taiga UI
@Directive({
selector: 'ng-template[tuiAlert]',
providers: [
{
provide: AbstractTuiDialogService,
useExisting: TuiAlertService,
},
],
inputs: ['options: tuiAlertOptions', 'open: tuiAlert'],
outputs: ['openChange: tuiAlertChange'],
})
export class TuiAlertDirective<T> extends AbstractTuiDialogDirective<
TuiAlertOptions<T>
> {}

View File

@@ -1,8 +0,0 @@
import { NgModule } from '@angular/core'
import { TuiAlertDirective } from './alert.directive'
@NgModule({
declarations: [TuiAlertDirective],
exports: [TuiAlertDirective],
})
export class TuiAlertModule {}

View File

@@ -18,8 +18,6 @@ export * from './components/text-spinner/text-spinner.component.module'
export * from './components/ticker/ticker.component' export * from './components/ticker/ticker.component'
export * from './components/ticker/ticker.module' export * from './components/ticker/ticker.module'
export * from './directives/alert/alert.directive'
export * from './directives/alert/alert.module'
export * from './directives/responsive-col/responsive-col.directive' export * from './directives/responsive-col/responsive-col.directive'
export * from './directives/responsive-col/responsive-col.module' export * from './directives/responsive-col/responsive-col.module'
export * from './directives/responsive-col/responsive-col-viewport.directive' export * from './directives/responsive-col/responsive-col-viewport.directive'

View File

@@ -0,0 +1,17 @@
<tui-data-list>
<h3 class="title"><ng-content></ng-content></h3>
<tui-opt-group
*ngFor="let group of actions | keyvalue : asIsOrder"
[label]="group.key.toUpperCase()"
>
<button
*ngFor="let action of group.value"
tuiOption
class="item"
(click)="action.action()"
>
<tui-svg class="icon" [src]="action.icon"></tui-svg>
{{ action.label }}
</button>
</tui-opt-group>
</tui-data-list>

View File

@@ -0,0 +1,16 @@
.title {
margin: 0;
padding: 0 0.5rem 0.25rem;
white-space: nowrap;
font: var(--tui-font-text-l);
font-weight: bold;
}
.item {
justify-content: flex-start;
gap: 0.75rem;
}
.icon {
opacity: var(--tui-disabled-opacity);
}

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiDataListModule, TuiSvgModule } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
export interface Action {
icon: string
label: string
action: () => void
}
@Component({
selector: 'app-actions',
templateUrl: './actions.component.html',
styleUrls: ['./actions.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiDataListModule, TuiSvgModule, CommonModule],
})
export class ActionsComponent {
@Input()
actions: Record<string, readonly Action[]> = {}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,9 +1,14 @@
<span class="link"> <span class="link">
<img alt="" class="icon" [src]="appCard.icon" /> <img alt="" class="icon" [src]="icon" />
<label ticker class="title">{{ appCard.title }}</label> <label ticker class="title">{{ title }}</label>
</span> </span>
<span class="side"> <span class="side">
<tui-hosted-dropdown [content]="content" (click.stop.prevent)="(0)"> <tui-hosted-dropdown
#dropdown
[content]="content"
(click.stop.prevent)="(0)"
(pointerdown.stop)="(0)"
>
<button <button
tuiIconButton tuiIconButton
appearance="outline" appearance="outline"
@@ -14,26 +19,12 @@
Actions Actions
</button> </button>
<ng-template #content> <ng-template #content>
<!-- TODO: Move menu to a separate component --> <app-actions
<tui-data-list> [actions]="(actions | toDesktopActions : id | async) || {}"
<h3 class="menu-title">{{ appCard.title }}</h3> (click)="dropdown.openChange.next(false)"
<tui-opt-group label="LAUNCH"> >
<button tuiOption class="menu-item"> {{ title }}
<tui-svg src="tuiIconLogOut" class="menu-icon"></tui-svg> </app-actions>
Tor
</button>
</tui-opt-group>
<tui-opt-group label="MANAGE">
<button tuiOption class="menu-item">
<tui-svg src="tuiIconSliders" class="menu-icon"></tui-svg>
Console
</button>
<button tuiOption class="menu-item">
<tui-svg src="tuiIconX" class="menu-icon"></tui-svg>
Remove from desktop
</button>
</tui-opt-group>
</tui-data-list>
</ng-template> </ng-template>
</tui-hosted-dropdown> </tui-hosted-dropdown>
</span> </span>

View File

@@ -43,20 +43,3 @@
// TODO: Theme // TODO: Theme
background: #4b4a4a; background: #4b4a4a;
} }
.menu-title {
margin: 0;
padding: 0 0.5rem 0.25rem;
white-space: nowrap;
font: var(--tui-font-text-l);
font-weight: bold;
}
.menu-item {
justify-content: flex-start;
gap: 0.75rem;
}
.menu-icon {
opacity: var(--tui-disabled-opacity);
}

View File

@@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -13,10 +14,10 @@ import {
TuiHostedDropdownModule, TuiHostedDropdownModule,
TuiSvgModule, TuiSvgModule,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { import { NavigationService } from '../navigation/navigation.service'
NavigationItem, import { Action, ActionsComponent } from '../actions/actions.component'
NavigationService, import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions'
} from '../navigation/navigation.service' import { toRouterLink } from '../../utils/to-router-link'
@Component({ @Component({
selector: '[appCard]', selector: '[appCard]',
@@ -25,22 +26,37 @@ import {
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule,
RouterLink, RouterLink,
TuiButtonModule, TuiButtonModule,
TuiHostedDropdownModule, TuiHostedDropdownModule,
TuiDataListModule, TuiDataListModule,
TuiSvgModule, TuiSvgModule,
TickerModule, TickerModule,
ActionsComponent,
ToDesktopActionsPipe,
], ],
}) })
export class CardComponent { export class CardComponent {
private readonly navigation = inject(NavigationService) private readonly navigation = inject(NavigationService)
@Input({ required: true }) @Input()
appCard!: NavigationItem id = ''
@Input()
icon = ''
@Input()
title = ''
@Input()
actions: Record<string, readonly Action[]> = {}
@HostListener('click') @HostListener('click')
onClick() { onClick() {
this.navigation.addTab(this.appCard) const { id, icon, title } = this
const routerLink = toRouterLink(id)
this.navigation.addTab({ icon, title, routerLink })
} }
} }

View File

@@ -13,26 +13,37 @@
> >
Enter service name Enter service name
</tui-input> </tui-input>
<h2 class="title">System Utilities</h2> <tui-scrollbar class="scrollbar">
<div class="items"> <h2 class="title">System Utilities</h2>
<a <div class="items">
*ngFor="let item of system | tuiFilter : bySearch : search; empty: empty" <a
[appCard]="item" *ngFor="
[routerLink]="item.routerLink" let item of system | keyvalue | tuiFilter : bySearch : search;
(click)="open = false" empty: empty
></a> "
</div> appCard
<h2 class="title">Installed services</h2> [id]="item.key"
<div class="items"> [title]="item.value.title"
<a [icon]="item.value.icon"
*ngFor=" [routerLink]="item.key"
let item of (services$ | async) || [] | tuiFilter : bySearch : search; (click)="open = false"
empty: empty ></a>
" </div>
[appCard]="item" <h2 class="title">Installed services</h2>
[routerLink]="item.routerLink" <div class="items">
(click)="open = false" <a
></a> *ngFor="
</div> let item of (services$ | async) || [] | tuiFilter : bySearch : search;
<ng-template #empty>Nothing found</ng-template> empty: empty
"
appCard
[id]="item.manifest.id"
[icon]="item.icon"
[title]="item.manifest.title"
[routerLink]="getLink(item.manifest.id)"
(click)="open = false"
></a>
</div>
<ng-template #empty>Nothing found</ng-template>
</tui-scrollbar>
</div> </div>

View File

@@ -7,7 +7,7 @@
top: 100%; top: 100%;
left: 0; left: 0;
width: 100%; width: 100%;
min-height: calc(100% - 10.25rem); height: calc(100% - 10.25rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// TODO: Theme // TODO: Theme
@@ -21,6 +21,9 @@
.content { .content {
flex: 1; flex: 1;
height: 100%;
display: flex;
flex-direction: column;
background: inherit; background: inherit;
} }
@@ -48,13 +51,17 @@
} }
} }
.scrollbar {
margin-top: 1rem;
}
.search { .search {
max-width: 41rem; width: 25rem;
margin: 6rem auto 0; margin: 6rem auto 0;
} }
.title { .title {
margin: 5rem 0 1.25rem; margin: 4rem 0 1.25rem;
text-align: center; text-align: center;
text-transform: uppercase; text-transform: uppercase;
font: var(--tui-font-text-xl); font: var(--tui-font-text-xl);

View File

@@ -13,14 +13,16 @@ import {
TuiFilterPipeModule, TuiFilterPipeModule,
TuiForModule, TuiForModule,
} from '@taiga-ui/cdk' } from '@taiga-ui/cdk'
import { TuiSvgModule, TuiTextfieldControllerModule } from '@taiga-ui/core' import {
TuiScrollbarModule,
TuiSvgModule,
TuiTextfieldControllerModule,
} from '@taiga-ui/core'
import { TuiInputModule } from '@taiga-ui/kit' import { TuiInputModule } from '@taiga-ui/kit'
import { map } from 'rxjs'
import { CardComponent } from '../card/card.component' import { CardComponent } from '../card/card.component'
import { NavigationItem } from '../navigation/navigation.service'
import { ServicesService } from '../../services/services.service' import { ServicesService } from '../../services/services.service'
import { SYSTEM_UTILITIES } from './drawer.const' import { SYSTEM_UTILITIES } from './drawer.const'
import { toNavigationItem } from '../../utils/to-navigation-item' import { toRouterLink } from '../../utils/to-router-link'
@Component({ @Component({
selector: 'app-drawer', selector: 'app-drawer',
@@ -32,6 +34,7 @@ import { toNavigationItem } from '../../utils/to-navigation-item'
CommonModule, CommonModule,
FormsModule, FormsModule,
TuiSvgModule, TuiSvgModule,
TuiScrollbarModule,
TuiActiveZoneModule, TuiActiveZoneModule,
TuiInputModule, TuiInputModule,
TuiTextfieldControllerModule, TuiTextfieldControllerModule,
@@ -48,10 +51,13 @@ export class DrawerComponent {
search = '' search = ''
readonly system = SYSTEM_UTILITIES readonly system = SYSTEM_UTILITIES
readonly services$ = inject(ServicesService).pipe( readonly services$ = inject(ServicesService)
map(services => services.map(toNavigationItem)),
)
readonly bySearch = (item: NavigationItem, search: string): boolean => readonly bySearch = (item: any, search: string): boolean =>
search.length < 2 || TUI_DEFAULT_MATCHER(item.title, search) search.length < 2 ||
TUI_DEFAULT_MATCHER(item.manifest?.title || item.value?.title || '', search)
getLink(id: string): string {
return toRouterLink(id)
}
} }

View File

@@ -1,24 +1,19 @@
import { NavigationItem } from '../navigation/navigation.service' export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
export const SYSTEM_UTILITIES: readonly NavigationItem[] = [
{ {
title: 'Devices', '/portal/system/devices': {
routerLink: 'devices', icon: 'assets/img/icon_transparent.png',
icon: 'assets/img/icon_transparent.png', title: 'Devices',
}, },
{ '/portal/system/metrics': {
title: 'Metrics', icon: 'assets/img/icon_transparent.png',
routerLink: 'metrics', title: 'Metrics',
icon: 'assets/img/icon_transparent.png', },
}, '/portal/system/manual': {
{ icon: 'assets/img/icon_transparent.png',
title: 'User manual', title: 'Manual',
routerLink: 'manual', },
icon: 'assets/img/icon_transparent.png', '/portal/system/snek': {
}, icon: 'assets/img/icon_transparent.png',
{ title: 'Snek',
title: 'Snek', },
routerLink: 'snek', }
icon: 'assets/img/icon_transparent.png',
},
]

View File

@@ -0,0 +1,38 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { Action } from '../components/actions/actions.component'
import { filter, map, Observable } from 'rxjs'
import { DesktopService } from '../routes/desktop/desktop.service'
@Pipe({
name: 'toDesktopActions',
standalone: true,
})
export class ToDesktopActionsPipe implements PipeTransform {
private readonly desktop = inject(DesktopService)
transform(
value: Record<string, readonly Action[]>,
id: string,
): Observable<Record<string, readonly Action[]>> {
return this.desktop.desktop$.pipe(
filter(Boolean),
map(desktop => {
const action = desktop.includes(id)
? {
icon: 'tuiIconMinus',
label: 'Remove from Desktop',
action: () => this.desktop.remove(id),
}
: {
icon: 'tuiIconPlus',
label: 'Add to Desktop',
action: () => this.desktop.add(id),
}
return {
manage: [action],
}
}),
)
}
}

View File

@@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { SYSTEM_UTILITIES } from '../components/drawer/drawer.const'
import { NavigationItem } from '../components/navigation/navigation.service'
import { toRouterLink } from '../utils/to-router-link'
@Pipe({
name: 'toDesktopItem',
standalone: true,
})
export class ToDesktopItemPipe implements PipeTransform {
private readonly system = SYSTEM_UTILITIES
transform(
packages: Record<string, PackageDataEntry>,
id: string,
): NavigationItem {
const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id)
if (SYSTEM_UTILITIES[id]) {
return {
icon: item.icon,
title: item.title,
routerLink,
}
}
return {
icon: packages[id].icon,
title: packages[id].manifest.title,
routerLink,
}
}
}

View File

@@ -1,14 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { NavigationItem } from '../components/navigation/navigation.service'
import { toNavigationItem } from '../utils/to-navigation-item'
@Pipe({
name: 'toNavigationItem',
standalone: true,
})
export class ToNavigationItemPipe implements PipeTransform {
transform(service: PackageDataEntry): NavigationItem {
return toNavigationItem(service)
}
}

View File

@@ -6,4 +6,5 @@
main { main {
flex: 1; flex: 1;
overflow: hidden;
} }

View File

@@ -27,6 +27,11 @@ const ROUTES: Routes = [
m => m.ServicesModule, m => m.ServicesModule,
), ),
}, },
{
path: 'system',
loadChildren: () =>
import('./routes/system/system.module').then(m => m.SystemModule),
},
], ],
}, },
] ]

View File

@@ -1,5 +1,25 @@
<a <ng-container *ngIf="desktop$ | async as desktop">
*ngFor="let service of services$ | async" <tui-tiles
[appCard]="service | toNavigationItem" *ngIf="packages$ | async as packages"
[routerLink]="(service | toNavigationItem).routerLink" class="tiles"
></a> [debounce]="500"
[order]="order"
(orderChange)="onReorder($event, desktop)"
>
<tui-tile
*ngFor="let service of desktop; let index = index"
class="item"
[style.order]="order.get(index)"
>
<a
*ngIf="packages | toDesktopItem : service as item"
tuiTileHandle
appCard
[id]="service"
[title]="item.title"
[icon]="item.icon"
[routerLink]="item.routerLink"
></a>
</tui-tile>
</tui-tiles>
</ng-container>

View File

@@ -3,9 +3,16 @@
align-items: center; align-items: center;
align-content: center; align-content: center;
justify-content: center; justify-content: center;
flex-wrap: wrap;
height: 100%; height: 100%;
max-width: 56rem; max-width: 56rem;
margin: 0 auto; margin: 0 auto;
padding: 1rem 0;
}
.tiles {
width: 100%;
justify-content: center;
grid-template-columns: repeat(auto-fit, 12.5rem);
grid-auto-rows: 5.5rem;
gap: 2rem; gap: 2rem;
} }

View File

@@ -1,5 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ServicesService } from '../../services/services.service' import { PatchDB } from 'patch-db-client'
import { tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DesktopService } from './desktop.service'
@Component({ @Component({
templateUrl: 'desktop.component.html', templateUrl: 'desktop.component.html',
@@ -7,6 +10,26 @@ import { ServicesService } from '../../services/services.service'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DesktopComponent { export class DesktopComponent {
// TODO: Only show services added to desktop private readonly desktop = inject(DesktopService)
readonly services$ = inject(ServicesService)
readonly desktop$ = this.desktop.desktop$.pipe(
tap(() => (this.order = new Map())),
)
readonly packages$ =
inject<PatchDB<DataModel>>(PatchDB).watch$('package-data')
order = new Map()
onReorder(order: Map<number, number>, desktop: readonly string[]) {
this.order = order
const items: string[] = []
Array.from(this.order.entries()).forEach(([index, order]) => {
items[order] = desktop[index]
})
this.desktop.save(items)
}
} }

View File

@@ -1,9 +1,11 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { TuiTilesModule } from '@taiga-ui/kit'
import { DesktopComponent } from './desktop.component' import { DesktopComponent } from './desktop.component'
import { CardComponent } from '../../components/card/card.component' import { CardComponent } from '../../components/card/card.component'
import { ToNavigationItemPipe } from '../../pipes/to-navigation-item' import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions'
import { ToDesktopItemPipe } from '../../pipes/to-desktop-item'
const ROUTES: Routes = [ const ROUTES: Routes = [
{ {
@@ -16,7 +18,9 @@ const ROUTES: Routes = [
imports: [ imports: [
CommonModule, CommonModule,
CardComponent, CardComponent,
ToNavigationItemPipe, TuiTilesModule,
ToDesktopActionsPipe,
ToDesktopItemPipe,
RouterModule.forChild(ROUTES), RouterModule.forChild(ROUTES),
], ],
declarations: [DesktopComponent], declarations: [DesktopComponent],

View File

@@ -0,0 +1,52 @@
import { inject, Injectable } from '@angular/core'
import { TuiAlertService } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { BehaviorSubject, first } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class DesktopService {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
readonly desktop$ = new BehaviorSubject<readonly string[] | undefined>(
undefined,
)
constructor() {
inject<PatchDB<DataModel>>(PatchDB)
.watch$('ui', 'desktop')
.pipe(first())
.subscribe(desktop => {
if (!this.desktop$.value) {
this.desktop$.next(desktop)
}
})
}
add(id: string) {
this.desktop$.next(this.desktop$.value?.concat(id))
this.save(this.desktop$.value)
}
remove(id: string) {
this.desktop$.next(this.desktop$.value?.filter(x => x !== id))
this.save(this.desktop$.value)
}
save(ids: readonly string[] = []) {
this.api
.setDbValue(['desktop'], ids)
.catch(() =>
this.alerts
.open(
'Desktop might be out of sync. Please refresh the page to fix it.',
{ status: 'warning' },
)
.subscribe(),
)
}
}

View File

@@ -5,6 +5,7 @@ import { PatchDB } from 'patch-db-client'
import { tap } from 'rxjs' import { tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { NavigationService } from '../../components/navigation/navigation.service' import { NavigationService } from '../../components/navigation/navigation.service'
import { toRouterLink } from '../../utils/to-router-link'
@Component({ @Component({
templateUrl: 'service.component.html', templateUrl: 'service.component.html',
@@ -26,9 +27,9 @@ export class ServiceComponent {
this.router.navigate(['..'], { relativeTo: this.route }) this.router.navigate(['..'], { relativeTo: this.route })
} else { } else {
this.navigation.addTab({ this.navigation.addTab({
title: pkg.manifest.title,
routerLink: `/portal/services/${pkg.manifest.id}`,
icon: pkg.icon, icon: pkg.icon,
title: pkg.manifest.title,
routerLink: toRouterLink(pkg.manifest.id),
}) })
} }
}), }),

View File

@@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
template: 'Here be snek',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SnekComponent {}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
const ROUTES: Routes = [
{
path: 'snek',
loadComponent: () =>
import('./snek/snek.component').then(m => m.SnekComponent),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
declarations: [],
exports: [],
})
export class SystemModule {}

View File

@@ -1,13 +0,0 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { NavigationItem } from '../components/navigation/navigation.service'
export function toNavigationItem({
manifest,
icon,
}: PackageDataEntry): NavigationItem {
return {
title: manifest.title,
routerLink: `/portal/services/${manifest.id}`,
icon,
}
}

View File

@@ -0,0 +1,3 @@
export function toRouterLink(id: string): string {
return id.includes('/') ? id : `/portal/services/${id}`
}

View File

@@ -1,14 +1,17 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { TuiAlertModule } from '@start9labs/shared' import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import {
TuiAlertModule,
TuiButtonModule,
TuiDialogModule,
} from '@taiga-ui/core'
import { ToastContainerComponent } from './toast-container.component' import { ToastContainerComponent } from './toast-container.component'
import { NotificationsToastComponent } from './notifications-toast/notifications-toast.component' import { NotificationsToastComponent } from './notifications-toast/notifications-toast.component'
import { RefreshAlertComponent } from './refresh-alert/refresh-alert.component' import { RefreshAlertComponent } from './refresh-alert/refresh-alert.component'
import { UpdateToastComponent } from './update-toast/update-toast.component' import { UpdateToastComponent } from './update-toast/update-toast.component'
import { TuiButtonModule, TuiDialogModule } from '@taiga-ui/core'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
@NgModule({ @NgModule({
imports: [ imports: [

View File

@@ -7,6 +7,7 @@ export const mockPatchData: DataModel = {
name: `Matt's Server`, name: `Matt's Server`,
'ack-welcome': '1.0.0', 'ack-welcome': '1.0.0',
theme: 'Dark', theme: 'Dark',
desktop: ['lnd'],
widgets: BUILT_IN_WIDGETS.filter( widgets: BUILT_IN_WIDGETS.filter(
({ id }) => ({ id }) =>
id === 'favorites' || id === 'favorites' ||

View File

@@ -3,7 +3,6 @@ import { Url } from '@start9labs/shared'
import { Manifest } from '@start9labs/marketplace' import { Manifest } from '@start9labs/marketplace'
import { BackupJob } from '../api/api.types' import { BackupJob } from '../api/api.types'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants' import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { CustomSpec } from 'src/app/apps/ui/pages/system/domains/domain.const'
export interface DataModel { export interface DataModel {
'server-info': ServerInfo 'server-info': ServerInfo
@@ -23,6 +22,7 @@ export interface UIData {
'ack-instructions': Record<string, boolean> 'ack-instructions': Record<string, boolean>
theme: string theme: string
widgets: readonly Widget[] widgets: readonly Widget[]
desktop: readonly string[]
} }
export interface Widget { export interface Widget {