mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
refactor: implement breadcrumbs (#2552)
This commit is contained in:
7
web/projects/shared/assets/img/icons/home.svg
Normal file
7
web/projects/shared/assets/img/icons/home.svg
Normal 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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
@@ -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'])
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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, ['.']))
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user