From 0d079f0d8961ddd5f27c62ea61e0c1e9da1ee13f Mon Sep 17 00:00:00 2001 From: Alex Inkin Date: Sun, 6 Aug 2023 23:30:53 +0400 Subject: [PATCH] feat(portal): implement drag and drop add/remove (#2383) --- .../components/card/card.component.html | 2 +- .../components/card/card.component.scss | 1 - .../portal/components/card/card.component.ts | 2 - .../drawer/drawer-item.directive.ts | 87 +++++++++++++++++++ .../components/drawer/drawer.component.html | 2 + .../components/drawer/drawer.component.ts | 2 + .../apps/portal/pipes/to-desktop-actions.ts | 38 -------- .../app/apps/portal/pipes/to-desktop-item.ts | 4 +- .../routes/desktop/dektop-loading.service.ts | 37 ++++++++ .../routes/desktop/desktop-item.directive.ts | 40 +++++++++ .../routes/desktop/desktop.component.html | 38 +++++--- .../routes/desktop/desktop.component.scss | 30 +++++++ .../routes/desktop/desktop.component.ts | 56 +++++++----- .../portal/routes/desktop/desktop.module.ts | 7 +- .../portal/routes/desktop/desktop.service.ts | 52 ----------- .../apps/portal/services/desktop.service.ts | 53 +++++++++++ 16 files changed, 323 insertions(+), 128 deletions(-) create mode 100644 frontend/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts delete mode 100644 frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-actions.ts create mode 100644 frontend/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts create mode 100644 frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts delete mode 100644 frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.service.ts create mode 100644 frontend/projects/ui/src/app/apps/portal/services/desktop.service.ts diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html index bc0afe0f5..899d83144 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html @@ -20,7 +20,7 @@ {{ title }} diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss index b171fcad5..95d16a0fb 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss @@ -27,7 +27,6 @@ width: 2.5rem; height: 2.5rem; border-radius: 100%; - box-shadow: 0.25rem 0.25rem 0.25rem rgb(0 0 0 / 25%); } .title { diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts index 3675f19e3..6cf02c8a7 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts @@ -16,7 +16,6 @@ import { } from '@taiga-ui/core' 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({ @@ -34,7 +33,6 @@ import { toRouterLink } from '../../utils/to-router-link' TuiSvgModule, TickerModule, ActionsComponent, - ToDesktopActionsPipe, ], }) export class CardComponent { diff --git a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts new file mode 100644 index 000000000..4921d580b --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts @@ -0,0 +1,87 @@ +import { + Directive, + ElementRef, + HostListener, + inject, + Input, +} from '@angular/core' +import { tuiGetActualTarget, tuiIsElement, tuiPx } from '@taiga-ui/cdk' +import { DrawerComponent } from './drawer.component' +import { DesktopService } from '../../services/desktop.service' + +/** + * This directive is responsible for drag and drop of the drawer item. + * It saves item to desktop when dropped. + */ +@Directive({ + selector: '[drawerItem]', + standalone: true, + host: { + '[style.touchAction]': '"none"', + }, +}) +export class DrawerItemDirective { + private readonly desktop = inject(DesktopService) + private readonly drawer = inject(DrawerComponent) + private readonly element: HTMLElement = inject(ElementRef).nativeElement + + private x = NaN + private y = NaN + + @Input() + drawerItem = '' + + @HostListener('pointerdown.prevent.silent', ['$event']) + onStart(event: PointerEvent): void { + // This element is already on the desktop + if (this.desktop.items.includes(this.drawerItem)) return + + const target = tuiGetActualTarget(event) + const { x, y, pointerId } = event + const { left, top } = this.element.getBoundingClientRect() + + if (tuiIsElement(target)) { + target.releasePointerCapture(pointerId) + } + + this.drawer.open = false + this.onPointer(x - left, y - top) + } + + @HostListener('document:pointerup.silent') + onPointer(x = NaN, y = NaN): void { + // Some other element is dragged + if (Number.isNaN(this.x) && Number.isNaN(x)) return + + this.x = x + this.y = y + this.process(NaN, NaN) + } + + @HostListener('document:pointermove.silent', ['$event.x', '$event.y']) + onMove(x: number, y: number): void { + // This element is not dragged + if (Number.isNaN(this.x)) return + + this.process(x, y) + this.desktop.add('') + } + + private process(x: number, y: number) { + const { style } = this.element + const { items } = this.desktop + const dragged = !Number.isNaN(this.x + x) + + style.pointerEvents = dragged ? 'none' : '' + style.position = dragged ? 'fixed' : '' + style.top = dragged ? tuiPx(y - this.y) : '' + style.left = dragged ? tuiPx(x - this.x) : '' + + if (dragged || !items.includes('')) { + return + } + + this.desktop.items = items.map(item => item || this.drawerItem) + this.desktop.reorder(this.desktop.order) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html index 976e204e6..5fc199810 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html +++ b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html @@ -22,6 +22,7 @@ empty: empty " appCard + [drawerItem]="item.key" [id]="item.key" [title]="item.value.title" [icon]="item.value.icon" @@ -37,6 +38,7 @@ empty: empty " appCard + [drawerItem]="item.manifest.id" [id]="item.manifest.id" [icon]="item.icon" [title]="item.manifest.title" diff --git a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts index 24ad8fd4e..d516a0887 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts @@ -23,6 +23,7 @@ import { CardComponent } from '../card/card.component' import { ServicesService } from '../../services/services.service' import { SYSTEM_UTILITIES } from './drawer.const' import { toRouterLink } from '../../utils/to-router-link' +import { DrawerItemDirective } from './drawer-item.directive' @Component({ selector: 'app-drawer', @@ -41,6 +42,7 @@ import { toRouterLink } from '../../utils/to-router-link' TuiForModule, TuiFilterPipeModule, CardComponent, + DrawerItemDirective, RouterLink, ], }) diff --git a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-actions.ts b/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-actions.ts deleted file mode 100644 index 00cba35e9..000000000 --- a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-actions.ts +++ /dev/null @@ -1,38 +0,0 @@ -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, - id: string, - ): Observable> { - 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], - } - }), - ) - } -} diff --git a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts b/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts index edc182208..1769165c1 100644 --- a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts +++ b/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts @@ -14,7 +14,9 @@ export class ToDesktopItemPipe implements PipeTransform { transform( packages: Record, id: string, - ): NavigationItem { + ): NavigationItem | null { + if (!id) return null + const item = SYSTEM_UTILITIES[id] const routerLink = toRouterLink(id) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts b/frontend/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts new file mode 100644 index 000000000..2a4dc34c3 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts @@ -0,0 +1,37 @@ +import { inject, Injectable } from '@angular/core' +import { PatchDB } from 'patch-db-client' +import { + endWith, + ignoreElements, + Observable, + shareReplay, + startWith, + take, + tap, +} from 'rxjs' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { DesktopService } from '../../services/desktop.service' + +/** + * This service loads initial values for desktop items + * and is used to show loading indicator. + */ +@Injectable({ + providedIn: 'root', +}) +export class DektopLoadingService extends Observable { + private readonly desktop = inject(DesktopService) + private readonly patch = inject>(PatchDB) + private readonly loading = this.patch.watch$('ui', 'desktop').pipe( + take(1), + tap(items => (this.desktop.items = items.filter(Boolean))), + ignoreElements(), + startWith(true), + endWith(false), + shareReplay(1), + ) + + constructor() { + super(subscriber => this.loading.subscribe(subscriber)) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts new file mode 100644 index 000000000..4ebcc0edf --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts @@ -0,0 +1,40 @@ +import { + Directive, + ElementRef, + HostBinding, + inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core' +import { TuiTilesComponent } from '@taiga-ui/kit' + +/** + * This directive is responsible for creating empty placeholder + * on the desktop when item is dragged from the drawer + */ +@Directive({ + selector: '[desktopItem]', + standalone: true, +}) +export class DesktopItemDirective implements OnInit, OnDestroy { + private readonly element: Element = inject(ElementRef).nativeElement + private readonly tiles = inject(TuiTilesComponent) + + @Input() + desktopItem = '' + + @HostBinding('class._empty') + get empty(): boolean { + return !this.desktopItem + } + + ngOnInit() { + if (this.empty) this.tiles.element = this.element + } + + // TODO: Remove after Taiga UI updated to 3.40.0 + ngOnDestroy() { + if (this.tiles.element === this.element) this.tiles.element = null + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html index 0b1c1f05b..54a4d54d4 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html @@ -1,25 +1,41 @@ - + + + - + diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss index 8272b8ebc..ee97235f8 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss @@ -1,3 +1,5 @@ +@import '@taiga-ui/core/styles/taiga-ui-local'; + :host { display: flex; align-items: center; @@ -9,10 +11,38 @@ padding: 1rem 0; } +.loader { + height: 10rem; + width: 10rem; +} + .tiles { width: 100%; + min-height: 5.5rem; justify-content: center; grid-template-columns: repeat(auto-fit, 12.5rem); grid-auto-rows: 5.5rem; gap: 2rem; } + +.remove { + @include transition(background); + position: fixed; + bottom: 0; + left: calc(50% - 3rem); + width: 6rem; + height: 6rem; + border-radius: 100%; + background: var(--tui-base-02); + z-index: 10; + + &:hover { + background: var(--tui-base-01); + } +} + +.item._dragged, +.item._empty { + border-radius: var(--tui-radius-l); + box-shadow: inset 0 0 0 0.5rem var(--tui-clear-active); +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts index 6a638db01..d879372d3 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts @@ -1,35 +1,51 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + Component, + ElementRef, + inject, + QueryList, + ViewChild, + ViewChildren, +} from '@angular/core' +import { EMPTY_QUERY, TUI_PARENT_STOP } from '@taiga-ui/cdk' +import { tuiFadeIn, tuiScaleIn } from '@taiga-ui/core' +import { TuiTileComponent, TuiTilesComponent } from '@taiga-ui/kit' 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' +import { + DataModel, + PackageDataEntry, +} from 'src/app/services/patch-db/data-model' +import { DesktopService } from '../../services/desktop.service' +import { Observable } from 'rxjs' +import { DektopLoadingService } from './dektop-loading.service' @Component({ templateUrl: 'desktop.component.html', styleUrls: ['desktop.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, + animations: [TUI_PARENT_STOP, tuiScaleIn, tuiFadeIn], }) export class DesktopComponent { - private readonly desktop = inject(DesktopService) + @ViewChildren(TuiTileComponent, { read: ElementRef }) + private readonly tiles: QueryList = EMPTY_QUERY - readonly desktop$ = this.desktop.desktop$.pipe( - tap(() => (this.order = new Map())), - ) - - readonly packages$ = + readonly desktop = inject(DesktopService) + readonly loading$ = inject(DektopLoadingService) + readonly packages$: Observable> = inject>(PatchDB).watch$('package-data') - order = new Map() + @ViewChild(TuiTilesComponent) + readonly tile?: TuiTilesComponent - onReorder(order: Map, desktop: readonly string[]) { - this.order = order + onRemove() { + const element = this.tile?.element + const index = this.tiles + .toArray() + .map(({ nativeElement }) => nativeElement) + .indexOf(element) - const items: string[] = [] + this.desktop.remove(this.desktop.items[index]) + } - Array.from(this.order.entries()).forEach(([index, order]) => { - items[order] = desktop[index] - }) - - this.desktop.save(items) + onReorder(order: Map) { + this.desktop.reorder(order) } } diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts index 71adc1ed7..fa4e0e7f2 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts +++ b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.module.ts @@ -1,11 +1,12 @@ import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' +import { TuiLoaderModule, TuiSvgModule } from '@taiga-ui/core' import { TuiTilesModule } from '@taiga-ui/kit' import { DesktopComponent } from './desktop.component' import { CardComponent } from '../../components/card/card.component' -import { ToDesktopActionsPipe } from '../../pipes/to-desktop-actions' import { ToDesktopItemPipe } from '../../pipes/to-desktop-item' +import { DesktopItemDirective } from './desktop-item.directive' const ROUTES: Routes = [ { @@ -18,8 +19,10 @@ const ROUTES: Routes = [ imports: [ CommonModule, CardComponent, + DesktopItemDirective, + TuiSvgModule, + TuiLoaderModule, TuiTilesModule, - ToDesktopActionsPipe, ToDesktopItemPipe, RouterModule.forChild(ROUTES), ], diff --git a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.service.ts b/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.service.ts deleted file mode 100644 index 00c77e070..000000000 --- a/frontend/projects/ui/src/app/apps/portal/routes/desktop/desktop.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -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( - undefined, - ) - - constructor() { - inject>(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(), - ) - } -} diff --git a/frontend/projects/ui/src/app/apps/portal/services/desktop.service.ts b/frontend/projects/ui/src/app/apps/portal/services/desktop.service.ts new file mode 100644 index 000000000..ac63cfbbd --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/services/desktop.service.ts @@ -0,0 +1,53 @@ +import { inject, Injectable } from '@angular/core' +import { TuiAlertService } from '@taiga-ui/core' +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) + + order = new Map() + items: readonly string[] = [] + + add(id: string) { + if (this.items.includes(id)) return + + this.items = this.items.concat(id) + this.save(this.items) + } + + remove(id: string) { + if (!this.items.includes(id)) return + + this.items = this.items.filter(x => x !== id) + this.save(this.items) + } + + save(ids: readonly string[] = []) { + this.api + .setDbValue(['desktop'], Array.from(new Set(ids))) + .catch(() => + this.alerts + .open( + 'Desktop might be out of sync. Please refresh the page to fix it.', + { status: 'warning' }, + ) + .subscribe(), + ) + } + + reorder(order: Map) { + this.order = order + + const items: string[] = [...this.items] + + Array.from(this.order.entries()).forEach(([index, order]) => { + items[order] = this.items[index] + }) + + this.save(items) + } +}