refactor: implement breadcrumbs (#2552)

This commit is contained in:
Alex Inkin
2024-01-23 10:32:11 +08:00
committed by GitHub
parent 90f5864f1e
commit 92aa70182d
26 changed files with 468 additions and 407 deletions

View File

@@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 34 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4.78958" width="26.4999" height="26.4999" rx="3.53333" stroke="black" stroke-width="1.76666" stroke-linejoin="round"/>
<rect x="8.47266" y="9.2063" width="7" height="7" rx="0.883332" fill="black"/>
<rect x="8.47266" y="18.9231" width="7" height="7.94998" rx="0.883332" fill="black"/>
<rect x="19.0723" y="22.4563" width="7" height="4.41666" rx="0.883332" fill="black"/>
<rect x="19.0723" y="9.2063" width="7" height="10.6" rx="0.883332" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -1,3 +1,17 @@
<svg class="definitions" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="round-corners">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
result="flt_tag"
/>
<feComposite in="SourceGraphic" in2="flt_tag" operator="atop" />
</filter>
</defs>
</svg>
<tui-root
*ngIf="widgetDrawer$ | async as drawer"
tuiTheme="night"

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -13,7 +13,7 @@ tui-root {
.menu {
:host-context(body[data-theme='Light']) & {
--ion-color-base: #F4F4F5 !important;
--ion-color-base: #f4f4f5 !important;
}
}
@@ -124,3 +124,10 @@ tui-root {
transform: rotate(45deg);
}
}
.definitions {
position: absolute;
width: 0;
height: 0;
visibility: hidden;
}

View File

@@ -27,7 +27,6 @@ import { ThemeSwitcherService } from './services/theme-switcher.service'
import { DateTransformerService } from './services/date-transformer.service'
import { DatetimeTransformerService } from './services/datetime-transformer.service'
import { MarketplaceService } from './services/marketplace.service'
import { RoutingStrategyService } from './apps/portal/services/routing-strategy.service'
import { CategoryService } from './services/category.service'
const {
@@ -80,10 +79,6 @@ export const APP_PROVIDERS: Provider[] = [
provide: AbstractMarketplaceService,
useClass: MarketplaceService,
},
{
provide: RouteReuseStrategy,
useExisting: RoutingStrategyService,
},
{
provide: AbstractCategoryService,
useClass: CategoryService,

View File

@@ -3,7 +3,6 @@ import {
ChangeDetectionStrategy,
Component,
HostListener,
inject,
Input,
} from '@angular/core'
import {
@@ -15,9 +14,7 @@ import {
import { RouterLink } from '@angular/router'
import { TickerModule } from '@start9labs/shared'
import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core'
import { NavigationService } from '../services/navigation.service'
import { Action, ActionsComponent } from './actions.component'
import { toRouterLink } from '../utils/to-router-link'
@Component({
selector: '[appCard]',
@@ -123,8 +120,6 @@ import { toRouterLink } from '../utils/to-router-link'
],
})
export class CardComponent {
private readonly navigation = inject(NavigationService)
@Input({ required: true })
id!: string
@@ -144,14 +139,6 @@ export class CardComponent {
return !this.id.includes('/')
}
@HostListener('click')
onClick() {
const { id, icon, title } = this
const routerLink = toRouterLink(id)
this.navigation.addTab({ icon, title, routerLink })
}
// Prevents Firefox from starting a native drag
@HostListener('pointerdown.prevent')
onDown() {}

View File

@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
Input,
} from '@angular/core'
import { Breadcrumb } from '../../services/breadcrumbs.service'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import {
TUI_ANIMATION_OPTIONS,
tuiFadeIn,
tuiWidthCollapse,
} from '@taiga-ui/core'
@Component({
standalone: true,
selector: 'a[headerBreadcrumb]',
template: `
@if (item.icon?.startsWith('tuiIcon')) {
<tui-icon [icon]="item.icon || ''" />
} @else if (item.icon) {
<img [style.width.rem]="2" [src]="item.icon" [alt]="item.title" />
}
<span tuiTitle>
{{ item.title }}
@if (item.subtitle) {
<span tuiSubtitle="">{{ item.subtitle }}</span>
}
</span>
<ng-content />
`,
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)
}

View File

@@ -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: `
<ng-content />
@if (connection$ | async; as connection) {
<tui-icon
[title]="connection.message"
[icon]="connection.icon"
[style.color]="connection.color"
[style.margin.rem]="0.5"
></tui-icon>
{{ connection.message }}
}
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 2rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, AsyncPipe],
})

View File

@@ -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: `
<ng-content />
<tui-badged-content
*tuiLet="notificationService.unreadCount$ | async as unread"
[style.--tui-radius.%]="50"
>
<tui-badge-notification *ngIf="unread" tuiSlot="top" size="s">
{{ unread }}
</tui-badge-notification>
<button
tuiIconButton
iconLeft="tuiIconBellLarge"
appearance="icon"
size="s"
[style.color]="'var(--tui-text-01)'"
(click)="handleNotificationsClick(unread || 0)"
>
Notifications
</button>
</tui-badged-content>
<header-menu></header-menu>
<header-notifications
(onEmpty)="this.open$.next(false)"
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
/>
`,
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<HTMLElement>
private readonly _ = this.router.events.subscribe(() => {
this.open$.next(false)
})
readonly open$ = new Subject<boolean>()
@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')
}
}
}

View File

@@ -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: `
<ng-content></ng-content>
<header-connection [style.margin-left]="'auto'" />
<tui-badged-content
*tuiLet="notificationService.unreadCount$ | async as unread"
[style.--tui-radius.%]="50"
>
<tui-badge-notification *ngIf="unread" tuiSlot="bottom" size="s">
{{ unread }}
</tui-badge-notification>
<button
tuiIconButton
iconLeft="tuiIconBellLarge"
appearance="icon-warning"
(click)="handleNotificationsClick(unread || 0)"
<a headerHome routerLink="/portal/desktop" routerLinkActive="active">
<div class="plank"></div>
</a>
@for (item of breadcrumbs$ | async; track $index) {
<a
routerLinkActive="active"
[routerLink]="item.routerLink"
[routerLinkActiveOptions]="{ exact: true }"
[headerBreadcrumb]="item"
>
Notifications
</button>
</tui-badged-content>
<header-menu></header-menu>
<header-notifications
(onEmpty)="this.open$.next(false)"
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
/>
<div class="plank"></div>
</a>
}
<div [style.flex]="1"><div class="plank"></div></div>
<header-connection><div class="plank"></div></header-connection>
<header-corner><div class="plank"></div></header-corner>
`,
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<HTMLElement>
private readonly _ = this.router.events.subscribe(() => {
this.open$.next(false)
})
readonly open$ = new Subject<boolean>()
@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)
}

View File

@@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { TuiIconModule } from '@taiga-ui/experimental'
@Component({
standalone: true,
selector: 'a[headerHome]',
template: `
<ng-content />
<tui-icon icon="/assets/img/icons/home.svg" [style.font-size.rem]="2" />
`,
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 {}

View File

@@ -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,

View File

@@ -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: `
<a
class="tab"
routerLink="desktop"
routerLinkActive="tab_active"
[routerLinkActiveOptions]="{ exact: true }"
>
<tui-icon icon="tuiIconHome" class="icon" />
</a>
@for (tab of tabs$ | async; track tab) {
<a
#rla="routerLinkActive"
class="tab"
routerLinkActive="tab_active"
[routerLink]="tab.routerLink"
>
@if (tab.icon.startsWith('tuiIcon')) {
<tui-icon class="icon" [icon]="tab.icon" />
} @else {
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
}
<button
tuiIconButton
size="xs"
iconLeft="tuiIconClose"
appearance="icon"
class="close"
(click.stop.prevent)="removeTab(tab.routerLink, rla.isActive)"
>
Close
</button>
</a>
}
`,
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'])
}
}

View File

@@ -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: `
<header appHeader>{{ name$ | async }}</header>
<nav appNavigation></nav>
<main><router-outlet /></main>
<app-drawer />
`,
@@ -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 <router-outlet> 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<DataModel>).watch$('ui', 'name')
}

View File

@@ -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<DataModel>,
private readonly formDialog: FormDialogService,
private readonly desktop: DesktopService,
private readonly navigation: NavigationService,
) {
updateTab('/actions')
}
) {}
async handleAction(action: WithId<Action>) {
if (action.disabled) {
@@ -168,7 +162,6 @@ export class ServiceActionsRoute {
this.embassyApi
.setDbValue<boolean>(['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) {

View File

@@ -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}`)
}
}

View File

@@ -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: '<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id" />',
@@ -36,8 +35,4 @@ export class ServiceLogsRoute {
readonly fetch = async (params: RR.GetServerLogsReq) =>
this.api.getPackageLogs({ id: this.id, ...params })
constructor() {
updateTab('/logs')
}
}

View File

@@ -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)"
>
<tui-icon icon="tuiIconChevronLeft" />
{{ service.manifest.title }}
@@ -50,7 +45,6 @@ export class ServiceOutletComponent {
private readonly patch = inject(PatchDB<DataModel>)
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),
})
}
}

View File

@@ -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<DataModel>)
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])
}),
}

View File

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

View File

@@ -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()

View File

@@ -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<readonly Breadcrumb[]> {
private readonly patch = inject(PatchDB<DataModel>)
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<string, PackageDataEntry> = {},
): 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
}

View File

@@ -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<readonly NavigationItem[]>([])
getTabs(): Observable<readonly NavigationItem[]> {
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))
}
}

View File

@@ -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<string, DetachedRouteHandle>()
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, ['.']))
}
}

View File

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