mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
feat(portal): implement drag and drop add/remove (#2383)
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<app-actions
|
||||
[actions]="(actions | toDesktopActions : id | async) || {}"
|
||||
[actions]="actions"
|
||||
(click)="dropdown.openChange.next(false)"
|
||||
>
|
||||
{{ title }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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<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],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ export class ToDesktopItemPipe implements PipeTransform {
|
||||
transform(
|
||||
packages: Record<string, PackageDataEntry>,
|
||||
id: string,
|
||||
): NavigationItem {
|
||||
): NavigationItem | null {
|
||||
if (!id) return null
|
||||
|
||||
const item = SYSTEM_UTILITIES[id]
|
||||
const routerLink = toRouterLink(id)
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
private readonly desktop = inject(DesktopService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,41 @@
|
||||
<ng-container *ngIf="desktop$ | async as desktop">
|
||||
<tui-loader
|
||||
*ngIf="loading$ | async; else data"
|
||||
size="xl"
|
||||
class="loader"
|
||||
></tui-loader>
|
||||
<ng-template #data>
|
||||
<tui-svg
|
||||
*ngIf="tile?.element"
|
||||
class="remove"
|
||||
src="tuiIconTrash2Large"
|
||||
@tuiFadeIn
|
||||
@tuiScaleIn
|
||||
(pointerup)="onRemove()"
|
||||
></tui-svg>
|
||||
<tui-tiles
|
||||
*ngIf="packages$ | async as packages"
|
||||
class="tiles"
|
||||
@tuiParentStop
|
||||
[debounce]="500"
|
||||
[order]="order"
|
||||
(orderChange)="onReorder($event, desktop)"
|
||||
[order]="desktop.order"
|
||||
(orderChange)="onReorder($event)"
|
||||
>
|
||||
<tui-tile
|
||||
*ngFor="let service of desktop; let index = index"
|
||||
*ngFor="let item of desktop.items; let index = index"
|
||||
class="item"
|
||||
[style.order]="order.get(index)"
|
||||
[style.order]="desktop.order.get(index)"
|
||||
[desktopItem]="item"
|
||||
>
|
||||
<a
|
||||
*ngIf="packages | toDesktopItem : service as item"
|
||||
*ngIf="packages | toDesktopItem : item as desktopItem"
|
||||
tuiTileHandle
|
||||
appCard
|
||||
[id]="service"
|
||||
[title]="item.title"
|
||||
[icon]="item.icon"
|
||||
[routerLink]="item.routerLink"
|
||||
@tuiFadeIn
|
||||
[id]="item"
|
||||
[title]="desktopItem.title"
|
||||
[icon]="desktopItem.icon"
|
||||
[routerLink]="desktopItem.routerLink"
|
||||
></a>
|
||||
</tui-tile>
|
||||
</tui-tiles>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ElementRef> = 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<Record<string, PackageDataEntry>> =
|
||||
inject<PatchDB<DataModel>>(PatchDB).watch$('package-data')
|
||||
|
||||
order = new Map()
|
||||
@ViewChild(TuiTilesComponent)
|
||||
readonly tile?: TuiTilesComponent
|
||||
|
||||
onReorder(order: Map<number, number>, 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<number, number>) {
|
||||
this.desktop.reorder(order)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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<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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<number, number>) {
|
||||
this.order = order
|
||||
|
||||
const items: string[] = [...this.items]
|
||||
|
||||
Array.from(this.order.entries()).forEach(([index, order]) => {
|
||||
items[order] = this.items[index]
|
||||
})
|
||||
|
||||
this.save(items)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user