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: `
-
`,
@@ -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']
}