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

@@ -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.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.module'
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">
<img alt="" class="icon" [src]="appCard.icon" />
<label ticker class="title">{{ appCard.title }}</label>
<img alt="" class="icon" [src]="icon" />
<label ticker class="title">{{ title }}</label>
</span>
<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
tuiIconButton
appearance="outline"
@@ -14,26 +19,12 @@
Actions
</button>
<ng-template #content>
<!-- TODO: Move menu to a separate component -->
<tui-data-list>
<h3 class="menu-title">{{ appCard.title }}</h3>
<tui-opt-group label="LAUNCH">
<button tuiOption class="menu-item">
<tui-svg src="tuiIconLogOut" class="menu-icon"></tui-svg>
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>
<app-actions
[actions]="(actions | toDesktopActions : id | async) || {}"
(click)="dropdown.openChange.next(false)"
>
{{ title }}
</app-actions>
</ng-template>
</tui-hosted-dropdown>
</span>

View File

@@ -43,20 +43,3 @@
// TODO: Theme
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 {
ChangeDetectionStrategy,
Component,
@@ -13,10 +14,10 @@ import {
TuiHostedDropdownModule,
TuiSvgModule,
} from '@taiga-ui/core'
import {
NavigationItem,
NavigationService,
} from '../navigation/navigation.service'
import { NavigationService } from '../navigation/navigation.service'
import { Action, ActionsComponent } from '../actions/actions.component'
import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions'
import { toRouterLink } from '../../utils/to-router-link'
@Component({
selector: '[appCard]',
@@ -25,22 +26,37 @@ import {
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
RouterLink,
TuiButtonModule,
TuiHostedDropdownModule,
TuiDataListModule,
TuiSvgModule,
TickerModule,
ActionsComponent,
ToDesktopActionsPipe,
],
})
export class CardComponent {
private readonly navigation = inject(NavigationService)
@Input({ required: true })
appCard!: NavigationItem
@Input()
id = ''
@Input()
icon = ''
@Input()
title = ''
@Input()
actions: Record<string, readonly Action[]> = {}
@HostListener('click')
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
</tui-input>
<h2 class="title">System Utilities</h2>
<div class="items">
<a
*ngFor="let item of system | tuiFilter : bySearch : search; empty: empty"
[appCard]="item"
[routerLink]="item.routerLink"
(click)="open = false"
></a>
</div>
<h2 class="title">Installed services</h2>
<div class="items">
<a
*ngFor="
let item of (services$ | async) || [] | tuiFilter : bySearch : search;
empty: empty
"
[appCard]="item"
[routerLink]="item.routerLink"
(click)="open = false"
></a>
</div>
<ng-template #empty>Nothing found</ng-template>
<tui-scrollbar class="scrollbar">
<h2 class="title">System Utilities</h2>
<div class="items">
<a
*ngFor="
let item of system | keyvalue | tuiFilter : bySearch : search;
empty: empty
"
appCard
[id]="item.key"
[title]="item.value.title"
[icon]="item.value.icon"
[routerLink]="item.key"
(click)="open = false"
></a>
</div>
<h2 class="title">Installed services</h2>
<div class="items">
<a
*ngFor="
let item of (services$ | async) || [] | tuiFilter : bySearch : search;
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>

View File

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

View File

@@ -13,14 +13,16 @@ import {
TuiFilterPipeModule,
TuiForModule,
} 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 { map } from 'rxjs'
import { CardComponent } from '../card/card.component'
import { NavigationItem } from '../navigation/navigation.service'
import { ServicesService } from '../../services/services.service'
import { SYSTEM_UTILITIES } from './drawer.const'
import { toNavigationItem } from '../../utils/to-navigation-item'
import { toRouterLink } from '../../utils/to-router-link'
@Component({
selector: 'app-drawer',
@@ -32,6 +34,7 @@ import { toNavigationItem } from '../../utils/to-navigation-item'
CommonModule,
FormsModule,
TuiSvgModule,
TuiScrollbarModule,
TuiActiveZoneModule,
TuiInputModule,
TuiTextfieldControllerModule,
@@ -48,10 +51,13 @@ export class DrawerComponent {
search = ''
readonly system = SYSTEM_UTILITIES
readonly services$ = inject(ServicesService).pipe(
map(services => services.map(toNavigationItem)),
)
readonly services$ = inject(ServicesService)
readonly bySearch = (item: NavigationItem, search: string): boolean =>
search.length < 2 || TUI_DEFAULT_MATCHER(item.title, search)
readonly bySearch = (item: any, search: string): boolean =>
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: readonly NavigationItem[] = [
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
{
title: 'Devices',
routerLink: 'devices',
icon: 'assets/img/icon_transparent.png',
},
{
title: 'Metrics',
routerLink: 'metrics',
icon: 'assets/img/icon_transparent.png',
},
{
title: 'User manual',
routerLink: 'manual',
icon: 'assets/img/icon_transparent.png',
},
{
title: 'Snek',
routerLink: 'snek',
icon: 'assets/img/icon_transparent.png',
},
]
'/portal/system/devices': {
icon: 'assets/img/icon_transparent.png',
title: 'Devices',
},
'/portal/system/metrics': {
icon: 'assets/img/icon_transparent.png',
title: 'Metrics',
},
'/portal/system/manual': {
icon: 'assets/img/icon_transparent.png',
title: 'Manual',
},
'/portal/system/snek': {
icon: 'assets/img/icon_transparent.png',
title: 'Snek',
},
}

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 {
flex: 1;
overflow: hidden;
}

View File

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

View File

@@ -1,5 +1,25 @@
<a
*ngFor="let service of services$ | async"
[appCard]="service | toNavigationItem"
[routerLink]="(service | toNavigationItem).routerLink"
></a>
<ng-container *ngIf="desktop$ | async as desktop">
<tui-tiles
*ngIf="packages$ | async as packages"
class="tiles"
[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-content: center;
justify-content: center;
flex-wrap: wrap;
height: 100%;
max-width: 56rem;
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;
}

View File

@@ -1,5 +1,8 @@
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({
templateUrl: 'desktop.component.html',
@@ -7,6 +10,26 @@ import { ServicesService } from '../../services/services.service'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DesktopComponent {
// TODO: Only show services added to desktop
readonly services$ = inject(ServicesService)
private readonly desktop = inject(DesktopService)
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 { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TuiTilesModule } from '@taiga-ui/kit'
import { DesktopComponent } from './desktop.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 = [
{
@@ -16,7 +18,9 @@ const ROUTES: Routes = [
imports: [
CommonModule,
CardComponent,
ToNavigationItemPipe,
TuiTilesModule,
ToDesktopActionsPipe,
ToDesktopItemPipe,
RouterModule.forChild(ROUTES),
],
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 { DataModel } from 'src/app/services/patch-db/data-model'
import { NavigationService } from '../../components/navigation/navigation.service'
import { toRouterLink } from '../../utils/to-router-link'
@Component({
templateUrl: 'service.component.html',
@@ -26,9 +27,9 @@ export class ServiceComponent {
this.router.navigate(['..'], { relativeTo: this.route })
} else {
this.navigation.addTab({
title: pkg.manifest.title,
routerLink: `/portal/services/${pkg.manifest.id}`,
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 { NgModule } from '@angular/core'
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 { NotificationsToastComponent } from './notifications-toast/notifications-toast.component'
import { RefreshAlertComponent } from './refresh-alert/refresh-alert.component'
import { UpdateToastComponent } from './update-toast/update-toast.component'
import { TuiButtonModule, TuiDialogModule } from '@taiga-ui/core'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
@NgModule({
imports: [

View File

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

View File

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