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