diff --git a/web/projects/shared/assets/img/icons/home.svg b/web/projects/shared/assets/img/icons/home.svg new file mode 100644 index 000000000..90547de82 --- /dev/null +++ b/web/projects/shared/assets/img/icons/home.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/projects/ui/src/app/app.component.html b/web/projects/ui/src/app/app.component.html index 9c4a8f685..0742fad74 100644 --- a/web/projects/ui/src/app/app.component.html +++ b/web/projects/ui/src/app/app.component.html @@ -1,3 +1,17 @@ + + + + + + + + + + } @else if (item.icon) { + + } + + {{ item.title }} + @if (item.subtitle) { + {{ item.subtitle }} + } + + + `, + styles: [ + ` + :host { + display: flex; + align-items: center; + gap: 1rem; + min-width: 1.25rem; + white-space: nowrap; + text-transform: capitalize; + --clip-path: polygon( + calc(100% - 1.75rem) 0%, + calc(100% - 0.875rem) 50%, + 100% 100%, + 0% 100%, + 0.875rem 50%, + 0% 0% + ); + + &:not(.active) { + --clip-path: polygon( + calc(100% - 1.75rem) 0%, + calc(100% - 0.875rem) 50%, + calc(100% - 1.75rem) 100%, + 0% 100%, + 0.875rem 50%, + 0% 0% + ); + } + + & > * { + font-weight: bold; + gap: 0; + border-radius: 100%; + } + + &::before, + &::after { + content: ''; + margin: 0.5rem; + } + + &::before { + margin: 0.25rem; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiIconModule, TuiTitleModule], + animations: [tuiWidthCollapse, tuiFadeIn], +}) +export class HeaderBreadcrumbComponent { + @Input({ required: true, alias: 'headerBreadcrumb' }) + item!: Breadcrumb + + @HostBinding('@tuiFadeIn') + @HostBinding('@tuiWidthCollapse') + readonly animation = inject(TUI_ANIMATION_OPTIONS) +} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-connection.component.ts b/web/projects/ui/src/app/apps/portal/components/header/connection.component.ts similarity index 91% rename from web/projects/ui/src/app/apps/portal/components/header/header-connection.component.ts rename to web/projects/ui/src/app/apps/portal/components/header/connection.component.ts index 30bf666c2..cdf9a4377 100644 --- a/web/projects/ui/src/app/apps/portal/components/header/header-connection.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/header/connection.component.ts @@ -1,24 +1,34 @@ +import { AsyncPipe } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { TuiIconModule } from '@taiga-ui/experimental' import { PatchDB } from 'patch-db-client' import { combineLatest, map, Observable, startWith } from 'rxjs' import { ConnectionService } from 'src/app/services/connection.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { AsyncPipe } from '@angular/common' @Component({ standalone: true, selector: 'header-connection', template: ` + @if (connection$ | async; as connection) { + {{ connection.message }} } `, + styles: [ + ` + :host { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0 2rem; + } + `, + ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [TuiIconModule, AsyncPipe], }) diff --git a/web/projects/ui/src/app/apps/portal/components/header/corner.component.ts b/web/projects/ui/src/app/apps/portal/components/header/corner.component.ts new file mode 100644 index 000000000..80652611a --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/header/corner.component.ts @@ -0,0 +1,108 @@ +import { CommonModule } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + inject, + ViewChild, +} from '@angular/core' +import { Router } from '@angular/router' +import { TuiSidebarModule } from '@taiga-ui/addon-mobile' +import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk' +import { + TuiBadgedContentModule, + TuiBadgeNotificationModule, + TuiButtonModule, +} from '@taiga-ui/experimental' +import { Subject } from 'rxjs' +import { HeaderMenuComponent } from './menu.component' +import { HeaderNotificationsComponent } from './notifications.component' +import { SidebarDirective } from '../../../../app/sidebar-host.component' +import { NotificationService } from '../../services/notification.service' + +@Component({ + standalone: true, + selector: 'header-corner', + template: ` + + + + {{ unread }} + + + + + + `, + styles: [ + ` + :host { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.5rem 0 1.75rem; + --clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 1.75rem 100%); + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + HeaderMenuComponent, + HeaderNotificationsComponent, + SidebarDirective, + TuiBadgeNotificationModule, + TuiBadgedContentModule, + TuiButtonModule, + TuiLetModule, + TuiSidebarModule, + ], +}) +export class HeaderCornerComponent { + private readonly router = inject(Router) + readonly notificationService = inject(NotificationService) + + @ViewChild(HeaderNotificationsComponent, { read: ElementRef }) + private readonly panel?: ElementRef + + private readonly _ = this.router.events.subscribe(() => { + this.open$.next(false) + }) + + readonly open$ = new Subject() + + @HostListener('document:click.capture', ['$event.target']) + onClick(target: EventTarget | null) { + if ( + tuiIsElement(target) && + this.panel?.nativeElement && + !tuiContainsOrAfter(this.panel.nativeElement, target) + ) { + this.open$.next(false) + } + } + + handleNotificationsClick(unread: number) { + if (unread) { + this.open$.next(true) + } else { + this.router.navigateByUrl('/portal/system/notifications') + } + } +} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts index 3e052c1a2..6823d6f37 100644 --- a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts @@ -1,119 +1,93 @@ -import { CommonModule } from '@angular/common' -import { Router } from '@angular/router' -import { TuiSidebarModule } from '@taiga-ui/addon-mobile' -import { - ChangeDetectionStrategy, - Component, - ElementRef, - HostListener, - inject, - ViewChild, -} from '@angular/core' -import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk' -import { - TuiDataListModule, - TuiHostedDropdownModule, - TuiSvgModule, -} from '@taiga-ui/core' -import { - TuiBadgedContentModule, - TuiBadgeNotificationModule, - TuiButtonModule, -} from '@taiga-ui/experimental' -import { Subject } from 'rxjs' -import { SidebarDirective } from '../../../../app/sidebar-host.component' -import { HeaderMenuComponent } from './header-menu.component' -import { HeaderNotificationsComponent } from './header-notifications.component' -import { NotificationService } from '../../services/notification.service' -import { HeaderConnectionComponent } from './header-connection.component' +import { AsyncPipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { RouterLink, RouterLinkActive } from '@angular/router' +import { HeaderConnectionComponent } from './connection.component' +import { HeaderHomeComponent } from './home.component' +import { HeaderCornerComponent } from './corner.component' +import { HeaderBreadcrumbComponent } from './breadcrumb.component' +import { BreadcrumbsService } from '../../services/breadcrumbs.service' @Component({ selector: 'header[appHeader]', template: ` - - - - - {{ unread }} - - - - - +
+
+ } +
+
+
`, styles: [ ` + @import '@taiga-ui/core/styles/taiga-ui-local'; + :host { display: flex; - align-items: center; - height: 4.5rem; - padding: 0 1rem 0 2rem; - font-size: 1.5rem; - // TODO: Theme - background: rgb(51 51 51 / 84%); + height: 3.5rem; + padding: 0.375rem; + --clip-path: polygon( + 0% 0%, + calc(100% - 1.75rem) 0%, + 100% 100%, + 1.75rem 100% + ); + + > * { + @include transition(clip-path); + position: relative; + margin-left: -1.25rem; + backdrop-filter: blur(1rem); + clip-path: var(--clip-path); + } + } + + .plank { + @include transition(opacity); + position: absolute; + inset: 0; + z-index: -1; + filter: url(#round-corners); + opacity: 0.5; + + .active & { + opacity: 0.25; + } + + &::before { + @include transition(clip-path); + content: ''; + position: absolute; + inset: 0; + clip-path: var(--clip-path); + // TODO: Theme + background: #5f5f5f; + box-shadow: inset 0 1px rgb(255 255 255 / 25%); + } } `, ], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, - TuiBadgedContentModule, - TuiBadgeNotificationModule, - TuiButtonModule, - TuiHostedDropdownModule, - TuiDataListModule, - TuiSvgModule, - TuiSidebarModule, - SidebarDirective, - HeaderMenuComponent, - HeaderNotificationsComponent, + RouterLink, + RouterLinkActive, HeaderConnectionComponent, - TuiLetModule, + HeaderHomeComponent, + HeaderCornerComponent, + AsyncPipe, + HeaderBreadcrumbComponent, ], }) export class HeaderComponent { - private readonly router = inject(Router) - readonly notificationService = inject(NotificationService) - - @ViewChild(HeaderNotificationsComponent, { read: ElementRef }) - private readonly panel?: ElementRef - - private readonly _ = this.router.events.subscribe(() => { - this.open$.next(false) - }) - - readonly open$ = new Subject() - - @HostListener('document:click.capture', ['$event.target']) - onClick(target: EventTarget | null) { - if ( - tuiIsElement(target) && - this.panel?.nativeElement && - !tuiContainsOrAfter(this.panel.nativeElement, target) - ) { - this.open$.next(false) - } - } - - handleNotificationsClick(unread: number) { - if (unread) { - this.open$.next(true) - } else { - this.router.navigateByUrl('/portal/system/notifications') - } - } + readonly breadcrumbs$ = inject(BreadcrumbsService) } diff --git a/web/projects/ui/src/app/apps/portal/components/header/home.component.ts b/web/projects/ui/src/app/apps/portal/components/header/home.component.ts new file mode 100644 index 000000000..79fe59211 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/components/header/home.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { TuiIconModule } from '@taiga-ui/experimental' + +@Component({ + standalone: true, + selector: 'a[headerHome]', + template: ` + + + `, + styles: [ + ` + @import '@taiga-ui/core/styles/taiga-ui-local'; + + :host { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 2.5rem 0 1rem; + margin: 0 !important; + + --clip-path: polygon( + calc(100% - 1.75rem) 0%, + calc(100% - 0.875rem) 50%, + calc(100% - 1.75rem) 100%, + 0% 100%, + 0% 0% + ); + + &.active { + --clip-path: polygon( + calc(100% - 1.75rem) 0%, + calc(100% - 0.875rem) 50%, + 100% 100%, + 0% 100%, + 0% 0% + ); + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiIconModule], +}) +export class HeaderHomeComponent {} diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts b/web/projects/ui/src/app/apps/portal/components/header/menu.component.ts similarity index 100% rename from web/projects/ui/src/app/apps/portal/components/header/header-menu.component.ts rename to web/projects/ui/src/app/apps/portal/components/header/menu.component.ts diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts b/web/projects/ui/src/app/apps/portal/components/header/notification.component.ts similarity index 100% rename from web/projects/ui/src/app/apps/portal/components/header/header-notification.component.ts rename to web/projects/ui/src/app/apps/portal/components/header/notification.component.ts diff --git a/web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts b/web/projects/ui/src/app/apps/portal/components/header/notifications.component.ts similarity index 98% rename from web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts rename to web/projects/ui/src/app/apps/portal/components/header/notifications.component.ts index afe60b05e..e0e219738 100644 --- a/web/projects/ui/src/app/apps/portal/components/header/header-notifications.component.ts +++ b/web/projects/ui/src/app/apps/portal/components/header/notifications.component.ts @@ -18,7 +18,7 @@ import { import { PatchDB } from 'patch-db-client' import { Subject, first, tap } from 'rxjs' import { DataModel } from 'src/app/services/patch-db/data-model' -import { HeaderNotificationComponent } from './header-notification.component' +import { HeaderNotificationComponent } from './notification.component' import { toRouterLink } from '../../utils/to-router-link' import { ServerNotification, diff --git a/web/projects/ui/src/app/apps/portal/components/navigation.component.ts b/web/projects/ui/src/app/apps/portal/components/navigation.component.ts deleted file mode 100644 index 028055238..000000000 --- a/web/projects/ui/src/app/apps/portal/components/navigation.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { Router, RouterModule } from '@angular/router' -import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental' -import { NavigationService } from '../services/navigation.service' - -@Component({ - selector: 'nav[appNavigation]', - template: ` - - - - @for (tab of tabs$ | async; track tab) { - - @if (tab.icon.startsWith('tuiIcon')) { - - } @else { - - } - - - } - `, - styles: [ - ` - @import '@taiga-ui/core/styles/taiga-ui-local'; - - :host { - @include scrollbar-hidden; - - height: 3rem; - display: flex; - overflow: auto; - // TODO: Theme - background: rgb(97 95 95 / 84%); - } - - .tab { - position: relative; - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: center; - width: 7.5rem; - - &_active { - position: sticky; - left: 0; - right: 0; - z-index: 1; - // TODO: Theme - background: #373a3f; - } - } - - .icon { - width: 2rem; - height: 2rem; - border-radius: 100%; - color: var(--tui-base-08); - } - - .close { - position: absolute; - top: 0; - right: 0; - } - `, - ], - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, RouterModule, TuiButtonModule, TuiIconModule], -}) -export class NavigationComponent { - private readonly router = inject(Router) - private readonly navigation = inject(NavigationService) - - readonly tabs$ = this.navigation.getTabs() - - removeTab(routerLink: string, active: boolean) { - this.navigation.removeTab(routerLink) - - if (active) this.router.navigate(['/portal/desktop']) - } -} diff --git a/web/projects/ui/src/app/apps/portal/portal.component.ts b/web/projects/ui/src/app/apps/portal/portal.component.ts index 53bfe3d6a..b609af43e 100644 --- a/web/projects/ui/src/app/apps/portal/portal.component.ts +++ b/web/projects/ui/src/app/apps/portal/portal.component.ts @@ -1,18 +1,19 @@ import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { takeUntilDestroyed } from '@angular/core/rxjs-interop' +import { NavigationEnd, Router, RouterOutlet } from '@angular/router' import { tuiDropdownOptionsProvider } from '@taiga-ui/core' import { PatchDB } from 'patch-db-client' -import { HeaderComponent } from './components/header/header.component' -import { NavigationComponent } from './components/navigation.component' -import { DrawerComponent } from './components/drawer/drawer.component' +import { filter } from 'rxjs' import { DataModel } from 'src/app/services/patch-db/data-model' +import { HeaderComponent } from './components/header/header.component' +import { DrawerComponent } from './components/drawer/drawer.component' +import { BreadcrumbsService } from './services/breadcrumbs.service' @Component({ standalone: true, template: `
{{ name$ | async }}
-
`, @@ -31,13 +32,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model' `, ], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - CommonModule, - RouterOutlet, - HeaderComponent, - NavigationComponent, - DrawerComponent, - ], + imports: [CommonModule, RouterOutlet, HeaderComponent, DrawerComponent], providers: [ // TODO: Move to global tuiDropdownOptionsProvider({ @@ -46,5 +41,16 @@ import { DataModel } from 'src/app/services/patch-db/data-model' ], }) export class PortalComponent { + private readonly breadcrumbs = inject(BreadcrumbsService) + // TODO: Refactor to (activate) on when routing structure becomes flat + private readonly _ = inject(Router) + .events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(e => { + this.breadcrumbs.update(e.url.replace('/portal/service/', '')) + }) + readonly name$ = inject(PatchDB).watch$('ui', 'name') } diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts index b99b827b4..3a2856529 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts @@ -27,9 +27,6 @@ import { ServiceActionComponent } from '../components/action.component' import { ServiceActionSuccessComponent } from '../components/action-success.component' import { DesktopService } from '../../../services/desktop.service' import { GroupActionsPipe } from '../pipes/group-actions.pipe' -import { updateTab } from '../utils/update-tab' -import { NavigationService } from '../../../services/navigation.service' -import { toRouterLink } from '../../../utils/to-router-link' @Component({ template: ` @@ -88,10 +85,7 @@ export class ServiceActionsRoute { private readonly patch: PatchDB, private readonly formDialog: FormDialogService, private readonly desktop: DesktopService, - private readonly navigation: NavigationService, - ) { - updateTab('/actions') - } + ) {} async handleAction(action: WithId) { if (action.disabled) { @@ -168,7 +162,6 @@ export class ServiceActionsRoute { this.embassyApi .setDbValue(['ack-instructions', this.id], false) .catch(e => console.error('Failed to mark instructions as unseen', e)) - this.navigation.removeTab(toRouterLink(this.id)) this.desktop.remove(this.id) this.router.navigate(['portal', 'desktop']) } catch (e: any) { diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/interface.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/interface.component.ts index e1efc4305..e49c0e01b 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/interface.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/interface.component.ts @@ -5,7 +5,6 @@ import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addr import { DataModel } from 'src/app/services/patch-db/data-model' import { ActivatedRoute } from '@angular/router' import { getPkgId } from '@start9labs/shared' -import { updateTab } from '../utils/update-tab' @Component({ template: ` @@ -35,8 +34,4 @@ export class ServiceInterfaceRoute { 'interfaceInfo', this.context.interfaceId, ) - - constructor() { - updateTab(`/interface/${this.context.interfaceId}`) - } } diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts index 01da49b52..207317b85 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/logs.component.ts @@ -4,7 +4,6 @@ import { getPkgId } from '@start9labs/shared' import { LogsComponentModule } from 'src/app/common/logs/logs.component.module' import { ApiService } from 'src/app/services/api/embassy-api.service' import { RR } from 'src/app/services/api/api.types' -import { updateTab } from '../utils/update-tab' @Component({ template: '', @@ -36,8 +35,4 @@ export class ServiceLogsRoute { readonly fetch = async (params: RR.GetServerLogsReq) => this.api.getPackageLogs({ id: this.id, ...params }) - - constructor() { - updateTab('/logs') - } } diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts index 920e2e288..a0a1f4121 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts @@ -4,12 +4,8 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router' import { TuiIconModule } from '@taiga-ui/experimental' import { PatchDB } from 'patch-db-client' import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs' -import { - DataModel, - PackageDataEntry, -} from 'src/app/services/patch-db/data-model' +import { DataModel } from 'src/app/services/patch-db/data-model' import { toRouterLink } from '../../../utils/to-router-link' -import { NavigationService } from '../../../services/navigation.service' @Component({ template: ` @@ -18,7 +14,6 @@ import { NavigationService } from '../../../services/navigation.service' routerLinkActive="_current" [routerLinkActiveOptions]="{ exact: true }" [routerLink]="getLink(service.manifest.id)" - (isActiveChange)="onActive(service, $event)" > {{ service.manifest.title }} @@ -50,7 +45,6 @@ export class ServiceOutletComponent { private readonly patch = inject(PatchDB) private readonly route = inject(ActivatedRoute) private readonly router = inject(Router) - private readonly navigation = inject(NavigationService) readonly service$ = this.router.events.pipe( map(() => this.route.firstChild?.snapshot.paramMap?.get('pkgId')), @@ -61,11 +55,6 @@ export class ServiceOutletComponent { // if package disappears, navigate to list page if (!pkg) { this.router.navigate(['./portal/desktop']) - } else { - this.onActive( - pkg, - !this.navigation.hasSubtab(this.getLink(pkg.manifest.id)), - ) } }), ) @@ -73,14 +62,4 @@ export class ServiceOutletComponent { getLink(id: string): string { return toRouterLink(id) } - - onActive({ icon, manifest }: PackageDataEntry, active: boolean): void { - if (!active) return - - this.navigation.addTab({ - icon, - title: manifest.title, - routerLink: this.getLink(manifest.id), - }) - } } diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts index 0e5ff0cd0..21f6ca148 100644 --- a/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts @@ -36,7 +36,6 @@ import { } from 'src/app/services/dep-error.service' import { DependencyInfo } from '../types/dependency-info' import { Manifest } from '@start9labs/marketplace' -import { NavigationService } from '../../../services/navigation.service' import { toRouterLink } from '../../../utils/to-router-link' import { PackageConfigData } from '../types/package-config-data' import { ServiceConfigModal } from '../modals/config.component' @@ -108,7 +107,6 @@ export class ServiceRoute { private readonly patch = inject(PatchDB) private readonly pkgId = getPkgId(inject(ActivatedRoute)) private readonly depErrorService = inject(DepErrorService) - private readonly navigation = inject(NavigationService) private readonly router = inject(Router) private readonly formDialog = inject(FormDialogService) @@ -193,11 +191,6 @@ export class ServiceRoute { action: fixAction || (() => { - this.navigation.addTab({ - icon: depInfo.icon, - title: depInfo.title, - routerLink: toRouterLink(depId), - }) this.router.navigate([`portal`, `service`, depId]) }), } diff --git a/web/projects/ui/src/app/apps/portal/routes/service/utils/update-tab.ts b/web/projects/ui/src/app/apps/portal/routes/service/utils/update-tab.ts deleted file mode 100644 index cbc717315..000000000 --- a/web/projects/ui/src/app/apps/portal/routes/service/utils/update-tab.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { inject } from '@angular/core' -import { ActivatedRoute } from '@angular/router' -import { getPkgId } from '@start9labs/shared' -import { NavigationService } from 'src/app/apps/portal/services/navigation.service' -import { toRouterLink } from 'src/app/apps/portal/utils/to-router-link' - -export function updateTab(path: string, id = getPkgId(inject(ActivatedRoute))) { - inject(NavigationService).updateTab(toRouterLink(id), toRouterLink(id) + path) -} diff --git a/web/projects/ui/src/app/apps/portal/routes/system/sideload/package.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/sideload/package.component.ts index c415ec61e..9851f1ef3 100644 --- a/web/projects/ui/src/app/apps/portal/routes/system/sideload/package.component.ts +++ b/web/projects/ui/src/app/apps/portal/routes/system/sideload/package.component.ts @@ -23,8 +23,6 @@ import { DataModel } from 'src/app/services/patch-db/data-model' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ClientStorageService } from 'src/app/services/client-storage.service' -import { NavigationService } from '../../../services/navigation.service' - @Component({ selector: 'sideload-package', template: ` @@ -83,7 +81,6 @@ export class SideloadPackageComponent { private readonly api = inject(ApiService) private readonly errorService = inject(ErrorService) private readonly router = inject(Router) - private readonly navigation = inject(NavigationService) private readonly alerts = inject(TuiAlertService) private readonly emver = inject(Emver) @@ -133,7 +130,6 @@ export class SideloadPackageComponent { await this.api.uploadPackage(pkg, this.file) await this.router.navigate(['/portal/service', manifest.id]) - this.navigation.removeTab('/portal/system/sideload') this.alerts .open('Package uploaded successfully', { status: 'success' }) .subscribe() diff --git a/web/projects/ui/src/app/apps/portal/services/breadcrumbs.service.ts b/web/projects/ui/src/app/apps/portal/services/breadcrumbs.service.ts new file mode 100644 index 000000000..631ee0605 --- /dev/null +++ b/web/projects/ui/src/app/apps/portal/services/breadcrumbs.service.ts @@ -0,0 +1,88 @@ +import { inject, Injectable } from '@angular/core' +import { PatchDB } from 'patch-db-client' +import { BehaviorSubject } from 'rxjs' +import { + DataModel, + PackageDataEntry, +} from 'src/app/services/patch-db/data-model' +import { SYSTEM_UTILITIES } from '../constants/system-utilities' +import { toRouterLink } from '../utils/to-router-link' +import { getAllPackages } from '../../../util/get-package-data' + +export interface Breadcrumb { + title: string + routerLink: string + subtitle?: string + icon?: string +} + +@Injectable({ + providedIn: 'root', +}) +export class BreadcrumbsService extends BehaviorSubject { + private readonly patch = inject(PatchDB) + + constructor() { + super([]) + } + + async update(page: string) { + const packages = await getAllPackages(this.patch) + + try { + this.next(toBreadcrumbs(page, packages)) + } catch (e) { + this.next([]) + } + } +} + +function toBreadcrumbs( + id: string, + packages: Record = {}, +): Breadcrumb[] { + const item = SYSTEM_UTILITIES[id] + const routerLink = toRouterLink(id) + + if (id.startsWith('/portal/system/')) { + const [page, ...path] = id.replace('/portal/system/', '').split('/') + const service = `/portal/system/${page}` + const { icon, title } = SYSTEM_UTILITIES[service] + const breadcrumbs: Breadcrumb[] = [ + { + icon, + title, + routerLink: toRouterLink(service), + }, + ] + + if (path.length) { + breadcrumbs.push({ + title: path.join(': '), + routerLink: breadcrumbs[0].routerLink + '/' + path.join('/'), + }) + } + + return breadcrumbs + } + + const [service, ...path] = id.split('/') + const { icon, manifest } = packages[service] + const breadcrumbs: Breadcrumb[] = [ + { + icon, + title: manifest.title, + subtitle: manifest.version, + routerLink: toRouterLink(service), + }, + ] + + if (path.length) { + breadcrumbs.push({ + title: path.join(': '), + routerLink: breadcrumbs[0].routerLink + '/' + path.join('/'), + }) + } + + return breadcrumbs +} diff --git a/web/projects/ui/src/app/apps/portal/services/navigation.service.ts b/web/projects/ui/src/app/apps/portal/services/navigation.service.ts deleted file mode 100644 index a4a789d22..000000000 --- a/web/projects/ui/src/app/apps/portal/services/navigation.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@angular/core' -import { BehaviorSubject, Observable } from 'rxjs' -import { NavigationItem } from '../types/navigation-item' - -@Injectable({ - providedIn: 'root', -}) -export class NavigationService { - private readonly tabs = new BehaviorSubject([]) - - getTabs(): Observable { - return this.tabs - } - - removeTab(routerLink: string) { - this.tabs.next( - this.tabs.value.filter(t => !t.routerLink.startsWith(routerLink)), - ) - } - - addTab(tab: NavigationItem) { - const current = this.tabs.value.find(t => - t.routerLink.startsWith(tab.routerLink), - ) - - this.tabs.next( - current - ? this.tabs.value.map(t => (t === current ? tab : t)) - : this.tabs.value.concat(tab), - ) - } - - updateTab(old: string, routerLink: string) { - this.tabs.next( - this.tabs.value.map(t => - t.routerLink === old ? { ...t, routerLink } : t, - ), - ) - } - - hasTab(path: string): boolean { - return this.tabs.value.some(t => t.routerLink === path) - } - - hasSubtab(path: string): boolean { - return this.tabs.value.some(t => t.routerLink.startsWith(path)) - } -} diff --git a/web/projects/ui/src/app/apps/portal/services/routing-strategy.service.ts b/web/projects/ui/src/app/apps/portal/services/routing-strategy.service.ts deleted file mode 100644 index aa1778667..000000000 --- a/web/projects/ui/src/app/apps/portal/services/routing-strategy.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { inject, Injectable } from '@angular/core' -import { - ActivatedRouteSnapshot, - BaseRouteReuseStrategy, - createUrlTreeFromSnapshot, - DetachedRouteHandle, - UrlSerializer, -} from '@angular/router' -import { NavigationService } from './navigation.service' - -@Injectable({ - providedIn: 'root', -}) -export class RoutingStrategyService extends BaseRouteReuseStrategy { - private readonly url = inject(UrlSerializer) - private readonly navigation = inject(NavigationService) - private readonly handlers = new Map() - - override shouldDetach(route: ActivatedRouteSnapshot): boolean { - const path = this.getPath(route) - const store = this.navigation.hasTab(path) - - if (!store) this.handlers.delete(path) - - return store && path.startsWith('/portal/service') - } - - override store( - route: ActivatedRouteSnapshot, - handle: DetachedRouteHandle, - ): void { - this.handlers.set(this.getPath(route), handle) - } - - override shouldAttach(route: ActivatedRouteSnapshot): boolean { - return !!this.handlers.get(this.getPath(route)) - } - - override retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null { - return this.handlers.get(this.getPath(route)) || null - } - - override shouldReuseRoute( - { routeConfig, params }: ActivatedRouteSnapshot, - current: ActivatedRouteSnapshot, - ): boolean { - return ( - routeConfig === current.routeConfig && - Object.keys(params).length === Object.keys(current.params).length && - Object.keys(params).every(key => current.params[key] === params[key]) - ) - } - - private getPath(route: ActivatedRouteSnapshot): string { - return this.url.serialize(createUrlTreeFromSnapshot(route, ['.'])) - } -} diff --git a/web/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts b/web/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts index 02d355d3c..ff5bd5ee1 100644 --- a/web/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts +++ b/web/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts @@ -1,10 +1,5 @@ import { ActivatedRouteSnapshot } from '@angular/router' -import { inject } from '@angular/core' -import { NavigationService } from '../services/navigation.service' -import { NavigationItem } from '../types/navigation-item' export function systemTabResolver({ data }: ActivatedRouteSnapshot): string { - inject(NavigationService).addTab(data as NavigationItem) - return data['title'] }