feat: implement top navigation (#2805)

* feat: implement top navigation

* chore: fix order
This commit is contained in:
Alex Inkin
2024-12-30 20:07:44 +04:00
committed by GitHub
parent 89ab67e067
commit 57e75e3614
42 changed files with 542 additions and 644 deletions

View File

@@ -1,13 +1,13 @@
<header> <header>
<div class="title"> <div class="title">
<store-icon <store-icon
[class.tui-skeleton]="!registry" [tuiSkeleton]="!registry"
[class.tui-skeleton_rounded]="!registry" [style.border-radius.%]="!registry ? 100 : null"
size="60px" size="60px"
[url]="registry?.url || ''" [url]="registry?.url || ''"
[marketplace]="iconConfig" [marketplace]="iconConfig"
/> />
<h1 [class.tui-skeleton]="!registry"> <h1 [tuiSkeleton]="!registry">
{{ registry?.info?.name || 'Unnamed Registry' }} {{ registry?.info?.name || 'Unnamed Registry' }}
</h1> </h1>
<!-- change registry modal --> <!-- change registry modal -->
@@ -31,19 +31,18 @@
> >
<store-icon <store-icon
size="42px" size="42px"
[style.height]="'42px'" [style.height.px]="42"
[style.border-radius]="'100%'" [style.border-radius.%]="100"
[url]="registry?.url || ''" [url]="registry?.url || ''"
[marketplace]="iconConfig" [marketplace]="iconConfig"
[class.tui-skeleton]="!registry" [tuiSkeleton]="!registry"
[class.tui-skeleton_rounded]="!registry"
/> />
<nav <nav
*tuiSidebar="open; direction: 'right'; autoWidth: true" *tuiSidebar="open; direction: 'right'; autoWidth: true"
class="nav-mobile-sidebar divide-bar" class="nav-mobile-sidebar divide-bar"
> >
<div class="nav-mobile-sidebar-top"> <div class="nav-mobile-sidebar-top">
<h1 [class.tui-skeleton]="!registry"> <h1 [tuiSkeleton]="!registry">
{{ registry?.info?.name }} {{ registry?.info?.name }}
</h1> </h1>
<button <button

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { SharedPipesModule } from '@start9labs/shared' import { SharedPipesModule } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { MenuComponent } from './menu.component' import { MenuComponent } from './menu.component'
import { TuiLoader, TuiIcon, TuiButton, TuiAppearance } from '@taiga-ui/core' import { TuiLoader, TuiIcon, TuiButton, TuiAppearance } from '@taiga-ui/core'
@@ -25,6 +26,7 @@ import { StoreIconComponentModule } from '../store-icon/store-icon.component.mod
TuiLet, TuiLet,
TuiAppearance, TuiAppearance,
TuiIcon, TuiIcon,
TuiSkeleton,
], ],
declarations: [MenuComponent], declarations: [MenuComponent],
exports: [MenuComponent], exports: [MenuComponent],

View File

@@ -19,6 +19,7 @@ import { MarketplaceConfig, sameUrl } from '@start9labs/shared'
/> />
</ng-template> </ng-template>
`, `,
styles: ':host { overflow: hidden; }',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class StoreIconComponent { export class StoreIconComponent {

View File

@@ -39,11 +39,11 @@ export class CategoriesComponent {
readonly categoryChange = new EventEmitter<string>() readonly categoryChange = new EventEmitter<string>()
readonly fallback: Record<string, T.Category> = { readonly fallback: Record<string, T.Category> = {
a: { name: 'a', description: { short: 'a', long: 'a' } }, a: { name: '', description: { short: 'a', long: 'a' } },
b: { name: 'a', description: { short: 'a', long: 'a' } }, b: { name: '', description: { short: 'a', long: 'a' } },
c: { name: 'a', description: { short: 'a', long: 'a' } }, c: { name: '', description: { short: 'a', long: 'a' } },
d: { name: 'a', description: { short: 'a', long: 'a' } }, d: { name: '', description: { short: 'a', long: 'a' } },
e: { name: 'a', description: { short: 'a', long: 'a' } }, e: { name: '', description: { short: 'a', long: 'a' } },
} }
switchCategory(category: string): void { switchCategory(category: string): void {

View File

@@ -9,7 +9,6 @@ tui-root {
@include transition(filter); @include transition(filter);
height: 100%; height: 100%;
font-family: 'Open Sans', sans-serif; font-family: 'Open Sans', sans-serif;
--tui-skeleton-radius: 1rem;
&.offline { &.offline {
filter: saturate(0.75) contrast(0.85); filter: saturate(0.75) contrast(0.85);

View File

@@ -1,93 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
Input,
} from '@angular/core'
import {
TUI_ANIMATIONS_SPEED,
tuiFadeIn,
TuiIcon,
TuiTitle,
tuiToAnimationOptions,
tuiWidthCollapse,
} from '@taiga-ui/core'
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
@Component({
standalone: true,
selector: 'a[headerBreadcrumb]',
template: `
@if (item.icon?.startsWith('@tui.')) {
<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: [TuiIcon, TuiTitle],
animations: [tuiWidthCollapse, tuiFadeIn],
})
export class HeaderBreadcrumbComponent {
@Input({ required: true, alias: 'headerBreadcrumb' })
item!: Breadcrumb
@HostBinding('@tuiFadeIn')
@HostBinding('@tuiWidthCollapse')
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
}

View File

@@ -1,95 +0,0 @@
import { TuiIcon } from '@taiga-ui/core'
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, Observable, startWith } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { NetworkService } from 'src/app/services/network.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
standalone: true,
selector: 'header-connection',
template: `
<ng-content />
@if (connection$ | async; as connection) {
<!-- data-connection is used to display color indicator in the header through :has() -->
<tui-icon
[icon]="connection.icon"
[style.color]="connection.color"
[style.font-size.em]="1.5"
[attr.data-connection]="connection.status"
/>
{{ connection.message }}
}
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 2rem;
}
:host-context(tui-root._mobile) {
display: none;
font-size: 1rem;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, AsyncPipe],
})
export class HeaderConnectionComponent {
readonly connection$: Observable<{
message: string
color: string
icon: string
status: string
}> = combineLatest([
inject(NetworkService),
inject(ConnectionService),
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'statusInfo')
.pipe(startWith({ restarting: false, shuttingDown: false })),
]).pipe(
map(([network, websocket, status]) => {
if (!network)
return {
message: 'No Internet',
color: 'var(--tui-status-negative)',
icon: '@tui.cloud-off',
status: 'error',
}
if (!websocket)
return {
message: 'Connecting',
color: 'var(--tui-status-warning)',
icon: '@tui.cloud-off',
status: 'warning',
}
if (status.shuttingDown)
return {
message: 'Shutting Down',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
if (status.restarting)
return {
message: 'Restarting',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
return {
message: 'Connected',
color: 'var(--tui-status-positive)',
icon: '@tui.cloud',
status: 'success',
}
}),
)
}

View File

@@ -1,80 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiSidebar } from '@taiga-ui/addon-mobile'
import { TuiLet } from '@taiga-ui/cdk'
import {
TUI_ANIMATIONS_SPEED,
TuiButton,
tuiFadeIn,
tuiScaleIn,
tuiToAnimationOptions,
tuiWidthCollapse,
} from '@taiga-ui/core'
import { TuiBadgedContent, TuiBadgeNotification } from '@taiga-ui/kit'
import { SidebarDirective } from 'src/app/components/sidebar-host.component'
import { getMenu } from 'src/app/utils/system-utilities'
import { HeaderMenuComponent } from './menu.component'
@Component({
standalone: true,
selector: 'header-corner',
template: `
<ng-content />
@for (item of utils; track $index) {
@if (item.badge(); as badge) {
<tui-badged-content
[style.--tui-radius.%]="50"
[@tuiFadeIn]="animation"
[@tuiWidthCollapse]="animation"
[@tuiScaleIn]="animation"
>
<tui-badge-notification tuiSlot="top" size="s">
{{ badge }}
</tui-badge-notification>
<a
tuiIconButton
appearance="icon"
size="s"
[iconStart]="item.icon"
[routerLink]="item.routerLink"
[style.color]="'var(--tui-text-primary)'"
>
{{ item.name }}
</a>
</tui-badged-content>
}
}
<header-menu></header-menu>
`,
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%);
}
:host-context(tui-root._mobile) tui-badged-content {
display: none;
}
`,
],
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
HeaderMenuComponent,
SidebarDirective,
TuiBadgeNotification,
TuiBadgedContent,
TuiButton,
TuiLet,
TuiSidebar,
RouterLink,
],
})
export class HeaderCornerComponent {
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly utils = getMenu()
}

View File

@@ -1,47 +1,34 @@
import { AsyncPipe } from '@angular/common' import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
IsActiveMatchOptions, IsActiveMatchOptions,
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
} from '@angular/router' } from '@angular/router'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { HeaderConnectionComponent } from './connection.component'
import { HeaderHomeComponent } from './home.component'
import { HeaderCornerComponent } from './corner.component'
import { HeaderBreadcrumbComponent } from './breadcrumb.component'
import { HeaderSnekDirective } from './snek.directive'
import { HeaderMobileComponent } from './mobile.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service' import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderMenuComponent } from './menu.component'
import { HeaderMobileComponent } from './mobile.component'
import { HeaderNavigationComponent } from './navigation.component'
import { HeaderSnekDirective } from './snek.directive'
import { HeaderStatusComponent } from './status.component'
@Component({ @Component({
selector: 'header[appHeader]', selector: 'header[appHeader]',
template: ` template: `
<a headerHome routerLink="/portal/dashboard" routerLinkActive="active"> <header-navigation />
<div class="plaque"></div> <div class="item item_center" [headerMobile]="breadcrumbs$ | async">
</a>
@for (item of breadcrumbs$ | async; track $index) {
<a
routerLinkActive="active"
[routerLink]="item.routerLink"
[routerLinkActiveOptions]="options"
[headerBreadcrumb]="item"
>
<div class="plaque"></div>
</a>
}
<div [style.flex]="1" [headerMobile]="breadcrumbs$ | async">
<div class="plaque"></div>
<img <img
[appSnek]="(snekScore$ | async) || 0" [appSnek]="snekScore()"
class="snek" class="snek"
alt="Play Snake" alt="Play Snake"
src="assets/img/icons/snek.png" src="assets/img/icons/snek.png"
/> />
</div> </div>
<header-connection><div class="plaque"></div></header-connection> <header-status class="item item_connection" />
<header-corner><div class="plaque"></div></header-corner> <header-menu class="item item_corner" />
`, `,
styles: [ styles: [
` `
@@ -49,89 +36,62 @@ import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
:host { :host {
display: flex; display: flex;
height: 3.5rem; height: 2.75rem;
padding: var(--bumper); border-radius: var(--bumper);
--clip-path: polygon( margin: var(--bumper);
0% 0%, overflow: hidden;
calc(100% - 1.75rem) 0%,
100% 100%,
1.75rem 100%
);
> * { .item {
@include transition(all);
position: relative; position: relative;
margin-left: -1.25rem; border-radius: inherit;
backdrop-filter: blur(1rem); isolation: isolate;
clip-path: var(--clip-path);
}
> a:active,
> button:active {
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
}
&:has([data-connection='error']) {
--status: var(--tui-status-negative);
}
&:has([data-connection='warning']) {
--status: var(--tui-status-warning);
}
&:has([data-connection='neutral']) {
--status: var(--tui-status-neutral);
}
&:has([data-connection='success']) {
--status: var(--tui-status-positive);
}
}
header-connection .plaque::before {
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.25),
inset 0 -0.25rem var(--tui-status-positive);
}
:host-context(tui-root._mobile) {
a {
display: none;
}
header-corner .plaque::before {
box-shadow:
inset 0 1px rgb(255 255 255 / 25%),
inset -0.375rem 0 var(--status);
}
}
.plaque {
@include transition(opacity);
position: absolute;
inset: 0;
z-index: -1;
filter: url(#round-corners);
opacity: 0.5;
.active & {
opacity: 0.75;
&::before { &::before {
// TODO: Theme @include transition(all);
background: #363636; content: '';
position: absolute;
inset: 0;
border-radius: inherit;
backdrop-filter: blur(1rem);
transform: skewX(30deg);
background: rgb(75 75 75 / 65%);
box-shadow: inset 0 1px rgb(255 255 255 / 25%);
z-index: -1;
}
&_center {
flex: 1;
}
&_connection::before {
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.25),
inset 0 -0.75rem 0 -0.5rem var(--status);
}
&_corner {
margin-inline-start: var(--bumper);
&::before {
right: -2rem;
}
} }
} }
&::before { &:has([data-status='error']) {
@include transition(all); --status: var(--tui-status-negative);
content: ''; }
position: absolute;
inset: 0; &:has([data-status='warning']) {
clip-path: var(--clip-path); --status: var(--tui-status-warning);
// TODO: Theme }
background: #5f5f5f;
box-shadow: inset 0 1px rgb(255 255 255 / 25%); &:has([data-status='neutral']) {
--status: var(--tui-status-neutral);
}
&:has([data-status='success']) {
--status: var(--tui-status-positive);
} }
} }
@@ -147,6 +107,12 @@ import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
opacity: 1; opacity: 1;
} }
} }
:host-context(tui-root._mobile) {
.item_center::before {
left: -2rem;
}
}
`, `,
], ],
standalone: true, standalone: true,
@@ -155,22 +121,24 @@ import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
RouterLink, RouterLink,
RouterLinkActive, RouterLinkActive,
AsyncPipe, AsyncPipe,
HeaderConnectionComponent, HeaderStatusComponent,
HeaderHomeComponent, HeaderNavigationComponent,
HeaderCornerComponent,
HeaderSnekDirective, HeaderSnekDirective,
HeaderBreadcrumbComponent,
HeaderMobileComponent, HeaderMobileComponent,
HeaderMenuComponent,
], ],
}) })
export class HeaderComponent { export class HeaderComponent {
readonly options = OPTIONS readonly options = OPTIONS
readonly breadcrumbs$ = inject(BreadcrumbsService) readonly breadcrumbs$ = inject(BreadcrumbsService)
readonly snekScore$ = inject<PatchDB<DataModel>>(PatchDB).watch$( readonly snekScore = toSignal(
'ui', inject<PatchDB<DataModel>>(PatchDB).watch$(
'gaming', 'ui',
'snake', 'gaming',
'highScore', 'snake',
'highScore',
),
{ initialValue: 0 },
) )
} }

View File

@@ -1,46 +0,0 @@
import { TuiIcon } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component } from '@angular/core'
@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: [TuiIcon],
})
export class HeaderHomeComponent {}

View File

@@ -7,9 +7,10 @@ import {
TuiDropdown, TuiDropdown,
TuiIcon, TuiIcon,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiBadgeNotification, TuiDataListDropdownManager } from '@taiga-ui/kit' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { RESOURCES } from 'src/app/utils/resources' import { RESOURCES } from 'src/app/utils/resources'
import { getMenu } from 'src/app/utils/system-utilities' import { STATUS } from 'src/app/services/status.service'
import { ABOUT } from './about.component' import { ABOUT } from './about.component'
@Component({ @Component({
@@ -22,65 +23,43 @@ import { ABOUT } from './about.component'
[(tuiDropdownOpen)]="open" [(tuiDropdownOpen)]="open"
[tuiDropdownMaxHeight]="9999" [tuiDropdownMaxHeight]="9999"
> >
<img [style.max-width.%]="50" src="assets/img/icon.png" alt="StartOS" /> <img [style.max-width.%]="60" src="assets/img/icon.png" alt="StartOS" />
</button> </button>
<ng-template #content> <ng-template #content>
<tui-data-list tuiDataListDropdownManager [style.width.rem]="13"> @if (status().status !== 'success') {
@for (link of utils; track $index) { <div class="status">
<tui-icon [icon]="status().icon" />
{{ status().message }}
</div>
}
<tui-data-list [style.width.rem]="13">
<button tuiOption iconStart="@tui.info" (click)="about()">
About this server
</button>
<hr />
@for (link of links; track $index) {
<a <a
tuiOption tuiOption
class="item" target="_blank"
rel="noreferrer"
[iconStart]="link.icon" [iconStart]="link.icon"
[routerLink]="link.routerLink" [href]="link.href"
(click)="open = false"
> >
{{ link.name }} {{ link.name }}
@if (link.badge(); as badge) {
<tui-badge-notification>{{ badge }}</tui-badge-notification>
}
</a> </a>
@if (!$index || $index === 3 || $index === 5) {
<hr />
}
} }
<hr /> <hr />
<button <a
tuiOption tuiOption
class="item" iconStart="@tui.wrench"
tuiDropdownSided routerLink="/portal/system/settings"
iconStart="@tui.circle-help" (click)="open = false"
iconEnd="@tui.chevron-right"
[tuiDropdown]="dropdown"
[tuiDropdownOffset]="12"
[tuiDropdownManual]="false"
> >
Resources System Settings
<ng-template #dropdown> </a>
<tui-data-list> <hr />
<button <button tuiOption iconStart="@tui.log-out" (click)="logout()">
tuiOption Logout
iconStart="@tui.info"
class="item"
(click)="about()"
>
About this server
</button>
<hr />
@for (link of links; track $index) {
<a
tuiOption
class="item"
target="_blank"
rel="noreferrer"
iconEnd="@tui.external-link"
[iconStart]="link.icon"
[href]="link.href"
>
{{ link.name }}
</a>
}
</tui-data-list>
</ng-template>
</button> </button>
</tui-data-list> </tui-data-list>
</ng-template> </ng-template>
@@ -88,42 +67,54 @@ import { ABOUT } from './about.component'
styles: [ styles: [
` `
:host { :host {
margin: 0 -0.5rem; padding-inline-start: 0.5rem;
&._open::before {
filter: brightness(1.2);
}
} }
[tuiIconButton] { .status {
height: calc(var(--tui-height-m) + 0.25rem); display: flex;
width: calc(var(--tui-height-m) + 0.625rem); gap: 1rem;
align-items: center;
padding: 1rem 1rem 0.5rem;
opacity: 0.5;
} }
.item { [tuiOption] {
justify-content: flex-start; justify-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
:host-context(tui-root._mobile) {
[tuiIconButton] {
box-shadow: inset -1.25rem 0 0 -1rem var(--status);
}
}
`, `,
], ],
host: { '[class._open]': 'open' },
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [TuiDropdown, TuiDataList, TuiButton, TuiIcon, RouterLink],
TuiDropdown,
TuiDataList,
TuiButton,
TuiIcon,
RouterLink,
TuiBadgeNotification,
TuiDropdown,
TuiDataListDropdownManager,
],
}) })
export class HeaderMenuComponent { export class HeaderMenuComponent {
private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
open = false open = false
readonly utils = getMenu()
readonly links = RESOURCES readonly links = RESOURCES
readonly status = inject(STATUS)
about() { about() {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe() this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
} }
logout() {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified()
}
} }

View File

@@ -14,11 +14,7 @@ import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
selector: '[headerMobile]', selector: '[headerMobile]',
template: ` template: `
@if (headerMobile && headerMobile.length > 1) { @if (headerMobile && headerMobile.length > 1) {
<a <a [routerLink]="back" [style.padding.rem]="0.75">
[routerLink]="back"
[style.padding.rem]="0.75"
[queryParams]="queryParams"
>
<tui-icon icon="@tui.arrow-left" /> <tui-icon icon="@tui.arrow-left" />
</a> </a>
} }
@@ -39,20 +35,6 @@ import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
} }
} }
:host-context(tui-root._mobile) {
margin: 0;
--clip-path: polygon(
0% 0%,
calc(100% - 1.75rem) 0%,
100% 100%,
0% 100%
);
> * {
display: block;
}
}
.title { .title {
@include text-overflow(); @include text-overflow();
max-width: calc(100% - 5rem); max-width: calc(100% - 5rem);
@@ -62,6 +44,12 @@ import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
margin-inline-start: 1rem; margin-inline-start: 1rem;
} }
} }
:host-context(tui-root._mobile) {
> * {
display: block;
}
}
`, `,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -82,11 +70,7 @@ export class HeaderMobileComponent {
get back() { get back() {
return ( return (
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink || this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
'/portal/dashboard' '/portal/services'
) )
} }
get queryParams() {
return this.back === '/portal/dashboard' ? { tab: 'utilities' } : null
}
} }

View File

@@ -0,0 +1,175 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink, RouterLinkActive } from '@angular/router'
import {
TUI_ANIMATIONS_SPEED,
TuiButton,
tuiFadeIn,
TuiIcon,
tuiScaleIn,
tuiToAnimationOptions,
tuiWidthCollapse,
} from '@taiga-ui/core'
import { TuiBadgedContent, TuiBadgeNotification } from '@taiga-ui/kit'
import { getMenu } from 'src/app/utils/system-utilities'
@Component({
standalone: true,
selector: 'header-navigation',
template: `
@for (item of utils; track $index) {
<a
class="link"
routerLinkActive="link_active"
[routerLink]="item.routerLink"
>
<tui-badged-content
[style.--tui-radius.%]="50"
[@tuiFadeIn]="animation"
[@tuiWidthCollapse]="animation"
[@tuiScaleIn]="animation"
>
@if (item.badge(); as badge) {
<tui-badge-notification tuiSlot="top" size="s">
{{ badge }}
</tui-badge-notification>
}
<tui-icon [icon]="item.icon" />
</tui-badged-content>
<span>{{ item.name }}</span>
</a>
}
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
backdrop-filter: blur(1rem);
border-radius: inherit;
padding-inline-end: 0.75rem;
margin-inline-end: -0.4375rem;
isolation: isolate;
}
.link {
@include transition(all);
position: relative;
display: grid;
grid-template-columns: 1.5rem 0fr;
align-items: center;
padding: 0 0 0 1rem;
margin: 0;
border-radius: inherit;
color: var(--tui-text-secondary);
&:not(.link_active):hover tui-icon {
transform: scale(1.1);
}
&:not(.link_active):active tui-icon {
transform: scale(0.8);
}
&::before {
@include transition(all);
content: '';
position: absolute;
inset: 0;
transform: skewX(30deg);
background: rgb(75 75 75 / 65%);
box-shadow: inset 0 1px rgb(255 255 255 / 25%);
z-index: -1;
}
span {
@include transition(opacity);
position: relative;
overflow: hidden;
text-indent: 0.5rem;
opacity: 0;
}
&:hover,
&_active {
color: var(--tui-text-primary);
tui-icon {
color: var(--tui-text-primary);
}
}
&_active {
grid-template-columns: 1.5rem 1fr;
padding: 0 1rem;
margin: 0 var(--bumper);
+ .link::before {
border-top-left-radius: var(--bumper);
border-bottom-left-radius: var(--bumper);
}
&::before {
border-radius: var(--bumper);
filter: brightness(0.65);
}
span {
opacity: 1;
}
}
&:has(+ .link_active)::before {
border-top-right-radius: var(--bumper);
border-bottom-right-radius: var(--bumper);
}
&:has(~ .link_active) {
padding: 0 1rem 0 0;
}
&:first-child {
padding-inline-start: 1rem !important;
margin-inline-start: 0;
&::before {
left: -2rem;
}
}
&:last-child {
padding-inline-end: 1rem !important;
margin-inline-end: 0;
&::before {
border-top-right-radius: inherit;
border-bottom-right-radius: inherit;
}
}
}
tui-icon {
@include transition(transform);
color: var(--tui-text-secondary);
}
:host-context(tui-root._mobile) {
display: none;
}
`,
],
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiBadgeNotification,
TuiBadgedContent,
TuiButton,
RouterLink,
TuiIcon,
RouterLinkActive,
],
})
export class HeaderNavigationComponent {
readonly animation = tuiToAnimationOptions(inject(TUI_ANIMATIONS_SPEED))
readonly utils = getMenu()
}

View File

@@ -0,0 +1,55 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiIcon } from '@taiga-ui/core'
import { STATUS } from 'src/app/services/status.service'
@Component({
standalone: true,
selector: 'header-status',
template: `
<span>
<!-- data-status is used to display color indicator in the header through :has() -->
<tui-icon
[icon]="status().icon"
[style.color]="status().color"
[style.font-size.em]="1.5"
[attr.data-status]="status().status"
/>
</span>
<span>{{ status().message }}</span>
`,
styles: [
`
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
@include transition(all);
display: grid;
grid-template-columns: 1.75rem 1fr;
align-items: center;
padding: 0 1rem;
margin-inline-start: var(--bumper);
&._connected {
grid-template-columns: 0fr 0fr;
padding: 0;
margin: 0;
}
> * {
overflow: hidden;
}
}
:host-context(tui-root._mobile) {
display: none;
}
`,
],
host: { '[class._connected]': 'status().status === "success"' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIcon, AsyncPipe],
})
export class HeaderStatusComponent {
readonly status = inject(STATUS)
}

View File

@@ -15,7 +15,11 @@ import { BadgeService } from 'src/app/services/badge.service'
import { RESOURCES } from 'src/app/utils/resources' import { RESOURCES } from 'src/app/utils/resources'
import { getMenu } from 'src/app/utils/system-utilities' import { getMenu } from 'src/app/utils/system-utilities'
const FILTER = ['/portal/system/settings', '/portal/system/marketplace'] const FILTER = [
'/portal/services',
'/portal/system/settings',
'/portal/system/marketplace',
]
@Component({ @Component({
standalone: true, standalone: true,
@@ -25,7 +29,7 @@ const FILTER = ['/portal/system/settings', '/portal/system/marketplace']
<a <a
tuiTabBarItem tuiTabBarItem
icon="@tui.layout-grid" icon="@tui.layout-grid"
routerLink="/portal/dashboard" routerLink="/portal/services"
routerLinkActive routerLinkActive
(isActiveChange)="update()" (isActiveChange)="update()"
> >

View File

@@ -55,7 +55,7 @@ export class PortalComponent {
takeUntilDestroyed(), takeUntilDestroyed(),
) )
.subscribe(e => { .subscribe(e => {
this.breadcrumbs.update(e.url.replace('/portal/service/', '')) this.breadcrumbs.update(e.url.replace('/portal/services/', ''))
}) })
readonly name$ = inject<PatchDB<DataModel>>(PatchDB).watch$('ui', 'name') readonly name$ = inject<PatchDB<DataModel>>(PatchDB).watch$('ui', 'name')

View File

@@ -7,19 +7,12 @@ const ROUTES: Routes = [
component: PortalComponent, component: PortalComponent,
children: [ children: [
{ {
redirectTo: 'dashboard', redirectTo: 'services',
pathMatch: 'full', pathMatch: 'full',
path: '', path: '',
}, },
{ {
path: 'dashboard', path: 'services',
loadComponent: () =>
import('./routes/dashboard/dashboard.component').then(
m => m.DashboardComponent,
),
},
{
path: 'service',
loadChildren: () => loadChildren: () =>
import('./routes/service/service.module').then(m => m.ServiceModule), import('./routes/service/service.module').then(m => m.ServiceModule),
}, },

View File

@@ -1,26 +1,27 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core' import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
@Component({ @Component({
selector: 'service-health-check', selector: 'service-health-check',
template: ` template: `
@if (loading) { @if (loading) {
<tui-loader <tui-loader [tuiSkeleton]="!connected" [inheritColor]="!check.result" />
[class.tui-skeleton]="!connected"
[inheritColor]="!check.result"
/>
} @else { } @else {
<tui-icon <tui-icon
[icon]="icon" [icon]="icon"
[class.tui-skeleton]="!connected" [tuiSkeleton]="!connected"
[style.color]="color" [style.color]="color"
/> />
} }
<span tuiTitle> <span tuiTitle>
<strong [class.tui-skeleton]="!connected">{{ check.name }}</strong> <strong [tuiSkeleton]="!connected && 2">
<span tuiSubtitle [class.tui-skeleton]="!connected" [style.color]="color"> {{ connected ? check.name : '' }}
{{ message }} </strong>
<span tuiSubtitle [tuiSkeleton]="!connected && 3" [style.color]="color">
{{ connected ? message : '' }}
</span> </span>
</span> </span>
`, `,
@@ -36,9 +37,10 @@ import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
} }
`, `,
], ],
hostDirectives: [TuiCell],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiLoader, TuiIcon, TuiTitle], imports: [TuiLoader, TuiIcon, TuiTitle, TuiSkeleton],
}) })
export class ServiceHealthCheckComponent { export class ServiceHealthCheckComponent {
@Input({ required: true }) @Input({ required: true })

View File

@@ -9,12 +9,12 @@ import {
import { TuiLet } from '@taiga-ui/cdk' import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core' import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { map } from 'rxjs' import { map } from 'rxjs'
import { UILaunchComponent } from 'src/app/routes/portal/routes/dashboard/ui.component'
import { ControlsService } from 'src/app/services/controls.service' import { ControlsService } from 'src/app/services/controls.service'
import { DepErrorService } from 'src/app/services/dep-error.service' import { DepErrorService } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
import { UILaunchComponent } from './ui.component'
const RUNNING = ['running', 'starting', 'restarting'] const RUNNING = ['running', 'starting', 'restarting']

View File

@@ -1,19 +1,14 @@
import { TuiIcon } from '@taiga-ui/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { TuiIcon } from '@taiga-ui/core'
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest' import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { ServiceComponent } from 'src/app/routes/portal/routes/dashboard/service.component'
import { ServicesService } from 'src/app/routes/portal/routes/dashboard/services.service'
import { DepErrorService } from 'src/app/services/dep-error.service' import { DepErrorService } from 'src/app/services/dep-error.service'
import { ServiceComponent } from './service.component'
import { ServicesService } from './services.service'
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<h2>
<tui-icon icon="@tui.layout-grid" />
Services
</h2>
<div class="g-plaque"></div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -46,38 +41,8 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
position: relative; position: relative;
max-width: 64rem; max-width: 64rem;
margin: 0 auto; margin: 0 auto;
clip-path: var(--clip-path);
backdrop-filter: blur(1rem);
font-size: 1rem; font-size: 1rem;
overflow: hidden; overflow: hidden;
--clip-path: polygon(
0 2rem,
1.25rem 0,
8.75rem 0,
calc(10rem + 0.1em) calc(2rem - 0.1em),
calc(100% - 1.25rem) 2rem,
100% 4rem,
100% calc(100% - 2rem),
calc(100% - 1.25rem) 100%,
1.25rem 100%,
0 calc(100% - 2rem)
);
}
h2 {
height: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0 2rem;
font-weight: bold;
font-size: 1rem;
tui-icon {
font-size: 1rem;
}
} }
table { table {
@@ -105,15 +70,13 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
:host-context(tui-root._mobile) { :host-context(tui-root._mobile) {
height: calc(100vh - 7.375rem); height: calc(100vh - 7.375rem);
--clip-path: none !important;
table { table {
width: 100%; width: 100%;
margin: 0; margin: 0;
} }
thead, thead {
h2 {
display: none; display: none;
} }
} }

View File

@@ -8,12 +8,12 @@ import {
} from '@angular/core' } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
import { tuiPure } from '@taiga-ui/cdk' import { tuiPure } from '@taiga-ui/cdk'
import { ControlsComponent } from 'src/app/routes/portal/routes/dashboard/controls.component'
import { StatusComponent } from 'src/app/routes/portal/routes/dashboard/status.component'
import { ConnectionService } from 'src/app/services/connection.service' import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service' import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
import { ControlsComponent } from './controls.component'
import { StatusComponent } from './status.component'
@Component({ @Component({
standalone: true, standalone: true,
@@ -119,7 +119,7 @@ export class ServiceComponent implements OnChanges {
} }
get routerLink() { get routerLink() {
return `/portal/service/${this.manifest.id}` return `/portal/services/${this.manifest.id}`
} }
ngOnChanges() { ngOnChanges() {

View File

@@ -1,4 +1,3 @@
import { TuiLoader, TuiIcon } from '@taiga-ui/core'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -6,9 +5,10 @@ import {
Input, Input,
} from '@angular/core' } from '@angular/core'
import { tuiPure } from '@taiga-ui/cdk' import { tuiPure } from '@taiga-ui/cdk'
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../service/pipes/install-progress.pipe' import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
@Component({ @Component({
standalone: true, standalone: true,

View File

@@ -28,7 +28,7 @@ export class ServiceOutletComponent {
tap(pkg => { tap(pkg => {
// if package disappears, navigate to list page // if package disappears, navigate to list page
if (!pkg) { if (!pkg) {
this.router.navigate(['./portal/dashboard']) this.router.navigate(['./portal/services'])
} }
}), }),
) )

View File

@@ -33,7 +33,10 @@ const ROUTES: Routes = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
redirectTo: '/portal/dashboard', loadComponent: () =>
import('./dashboard/dashboard.component').then(
m => m.DashboardComponent,
),
}, },
], ],
}, },

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { UnitConversionPipesModule } from '@start9labs/shared' import { UnitConversionPipesModule } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core' import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { UnknownDisk } from 'src/app/services/api/api.types' import { UnknownDisk } from 'src/app/services/api/api.types'
@Component({ @Component({
@@ -54,7 +55,7 @@ import { UnknownDisk } from 'src/app/services/api/api.types'
</tr> </tr>
} @else { } @else {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -109,7 +110,7 @@ import { UnknownDisk } from 'src/app/services/api/api.types'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiButton, UnitConversionPipesModule], imports: [TuiButton, UnitConversionPipesModule, TuiSkeleton],
}) })
export class BackupsPhysicalComponent { export class BackupsPhysicalComponent {
@Input() @Input()

View File

@@ -13,7 +13,7 @@ import {
TuiIcon, TuiIcon,
TuiButton, TuiButton,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM } from '@taiga-ui/kit' import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
import { filter, map, Subject, switchMap } from 'rxjs' import { filter, map, Subject, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types' import { BackupTarget } from 'src/app/services/api/api.types'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe' import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
@@ -72,7 +72,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
} @else { } @else {
@for (i of ['', '']; track $index) { @for (i of ['', '']; track $index) {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -133,7 +133,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiButton, GetBackupIconPipe, TuiIcon, KeyValuePipe], imports: [TuiButton, GetBackupIconPipe, TuiIcon, KeyValuePipe, TuiSkeleton],
}) })
export class BackupsTargetsComponent { export class BackupsTargetsComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)

View File

@@ -2,6 +2,7 @@ import { TuiIcon } from '@taiga-ui/core'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { TuiSkeleton } from '@taiga-ui/kit'
import { CronJob } from 'cron' import { CronJob } from 'cron'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { from, map } from 'rxjs' import { from, map } from 'rxjs'
@@ -48,7 +49,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
} @else { } @else {
@for (row of ['', '']; track $index) { @for (row of ['', '']; track $index) {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -93,7 +94,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [GetBackupIconPipe, DatePipe, TuiIcon], imports: [GetBackupIconPipe, DatePipe, TuiIcon, TuiSkeleton],
}) })
export class BackupsUpcomingComponent { export class BackupsUpcomingComponent {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)

View File

@@ -1,5 +1,5 @@
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { TuiCheckbox } from '@taiga-ui/kit' import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -92,9 +92,7 @@ import { HasErrorPipe } from '../pipes/has-error.pipe'
} @else { } @else {
@for (row of ['', '']; track $index) { @for (row of ['', '']; track $index) {
<tr> <tr>
<td colspan="6"> <td colspan="6"><div [tuiSkeleton]="true">Loading</div></td>
<div class="tui-skeleton">Loading</div>
</td>
</tr> </tr>
} }
} }
@@ -166,6 +164,7 @@ import { HasErrorPipe } from '../pipes/has-error.pipe'
HasErrorPipe, HasErrorPipe,
GetBackupIconPipe, GetBackupIconPipe,
TuiCheckbox, TuiCheckbox,
TuiSkeleton,
], ],
}) })
export class BackupsHistoryModal { export class BackupsHistoryModal {

View File

@@ -8,7 +8,7 @@ import {
TuiButton, TuiButton,
TuiNotification, TuiNotification,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiConfirmData, TUI_CONFIRM } from '@taiga-ui/kit' import { TuiConfirmData, TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { BehaviorSubject, filter, from } from 'rxjs' import { BehaviorSubject, filter, from } from 'rxjs'
import { BackupJob } from 'src/app/services/api/api.types' import { BackupJob } from 'src/app/services/api/api.types'
@@ -85,9 +85,7 @@ import { EDIT } from './edit.component'
} @else { } @else {
@for (i of ['', '']; track $index) { @for (i of ['', '']; track $index) {
<tr> <tr>
<td colspan="5"> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
<div class="tui-skeleton">Loading</div>
</td>
</tr> </tr>
} }
} }
@@ -145,6 +143,7 @@ import { EDIT } from './edit.component'
TuiIcon, TuiIcon,
ToHumanCronPipe, ToHumanCronPipe,
GetBackupIconPipe, GetBackupIconPipe,
TuiSkeleton,
], ],
}) })
export class BackupsJobsModal implements OnInit { export class BackupsJobsModal implements OnInit {

View File

@@ -68,7 +68,7 @@ export class BackupsRestoreService {
), ),
) )
.subscribe(() => { .subscribe(() => {
this.router.navigate(['/portal/dashboard']) this.router.navigate(['/portal/services'])
}) })
} }

View File

@@ -147,7 +147,7 @@ export class MarketplaceControlsComponent {
} }
async showService() { async showService() {
this.router.navigate(['/portal/service', this.pkg.id]) this.router.navigate(['/portal/services', this.pkg.id])
} }
private async dryInstall(url: string) { private async dryInstall(url: string) {

View File

@@ -1,4 +1,4 @@
import { TuiLineClamp, TuiCheckbox } from '@taiga-ui/kit' import { TuiLineClamp, TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -60,7 +60,7 @@ import { NotificationItemComponent } from './item.component'
} @else { } @else {
@for (row of ['', '']; track $index) { @for (row of ['', '']; track $index) {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -76,7 +76,13 @@ import { NotificationItemComponent } from './item.component'
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [FormsModule, TuiCheckbox, TuiLineClamp, NotificationItemComponent], imports: [
FormsModule,
TuiCheckbox,
TuiLineClamp,
NotificationItemComponent,
TuiSkeleton,
],
}) })
export class NotificationsTableComponent implements OnChanges { export class NotificationsTableComponent implements OnChanges {
@Input() notifications?: ServerNotifications @Input() notifications?: ServerNotifications

View File

@@ -13,7 +13,7 @@ import {
TuiDialogService, TuiDialogService,
TuiLink, TuiLink,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit' import { TUI_CONFIRM, TuiSkeleton } from '@taiga-ui/kit'
import { filter } from 'rxjs' import { filter } from 'rxjs'
import { import {
FormComponent, FormComponent,
@@ -78,7 +78,7 @@ import { Proxy } from 'src/app/services/patch-db/data-model'
<tr><td colspan="5">No proxies added</td></tr> <tr><td colspan="5">No proxies added</td></tr>
} @else { } @else {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -122,7 +122,7 @@ import { Proxy } from 'src/app/services/patch-db/data-model'
`, `,
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiLink, TuiButton], imports: [CommonModule, TuiLink, TuiButton, TuiSkeleton],
}) })
export class ProxiesTableComponent { export class ProxiesTableComponent {
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)

View File

@@ -1,4 +1,4 @@
import { TuiCheckbox, TuiFade } from '@taiga-ui/kit' import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -63,7 +63,7 @@ import { PlatformInfoPipe } from './platform-info.pipe'
} @else { } @else {
@for (item of single ? [''] : ['', '']; track $index) { @for (item of single ? [''] : ['', '']; track $index) {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -123,6 +123,7 @@ import { PlatformInfoPipe } from './platform-info.pipe'
TuiIcon, TuiIcon,
TuiCheckbox, TuiCheckbox,
TuiFade, TuiFade,
TuiSkeleton,
], ],
}) })
export class SSHTableComponent<T extends Session> implements OnChanges { export class SSHTableComponent<T extends Session> implements OnChanges {

View File

@@ -8,7 +8,12 @@ import {
} from '@angular/core' } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core' import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core'
import { TuiConfirmData, TuiFade, TUI_CONFIRM } from '@taiga-ui/kit' import {
TuiConfirmData,
TuiFade,
TUI_CONFIRM,
TuiSkeleton,
} from '@taiga-ui/kit'
import { filter, take } from 'rxjs' import { filter, take } from 'rxjs'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component' import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { SSHKey } from 'src/app/services/api/api.types' import { SSHKey } from 'src/app/services/api/api.types'
@@ -51,7 +56,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
} @else { } @else {
@for (i of ['', '']; track $index) { @for (i of ['', '']; track $index) {
<tr> <tr>
<td colspan="5"><div class="tui-skeleton">Loading</div></td> <td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
</tr> </tr>
} }
} }
@@ -103,7 +108,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
`, `,
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButton, TuiFade], imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton],
}) })
export class SSHTableComponent { export class SSHTableComponent {
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)

View File

@@ -60,7 +60,7 @@ import { SideloadService } from './sideload.service'
<a <a
tuiButton tuiButton
appearance="tertiary-solid" appearance="tertiary-solid"
[routerLink]="'/portal/service/' + package.id" [routerLink]="'/portal/services/' + package.id"
> >
View installed View installed
</a> </a>

View File

@@ -0,0 +1,61 @@
import { inject, InjectionToken } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, startWith } from 'rxjs'
import { ConnectionService } from './connection.service'
import { NetworkService } from './network.service'
import { DataModel } from './patch-db/data-model'
export const STATUS = new InjectionToken('', {
factory: () =>
toSignal(
combineLatest({
network: inject(NetworkService),
websocket: inject(ConnectionService),
status: inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'statusInfo')
.pipe(startWith({ restarting: false, shuttingDown: false })),
}).pipe(
map(({ network, websocket, status }) => {
if (!network) return OFFLINE
if (!websocket) return CONNECTING
if (status.shuttingDown) return SHUTTING_DOWN
if (status.restarting) return RESTARTING
return CONNECTED
}),
),
{ initialValue: CONNECTED },
),
})
const OFFLINE = {
message: 'No Internet',
color: 'var(--tui-status-negative)',
icon: '@tui.cloud-off',
status: 'error',
}
const CONNECTING = {
message: 'Connecting',
color: 'var(--tui-status-warning)',
icon: '@tui.cloud-off',
status: 'warning',
}
const SHUTTING_DOWN = {
message: 'Shutting Down',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
const RESTARTING = {
message: 'Restarting',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
const CONNECTED = {
message: 'Connected',
color: 'var(--tui-status-positive)',
icon: '@tui.cloud',
status: 'success',
}

View File

@@ -4,38 +4,42 @@ import { BadgeService } from 'src/app/services/badge.service'
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> = export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
{ {
'/portal/system/notifications': { '/portal/services': {
icon: '@tui.bell', icon: '@tui.layout-grid',
title: 'Notifications', title: 'Services',
}, },
'/portal/system/marketplace': { '/portal/system/marketplace': {
icon: '@tui.shopping-cart', icon: '@tui.shopping-cart',
title: 'Marketplace', title: 'Marketplace',
}, },
// '/portal/system/updates': {
// icon: '@tui.globe',
// title: 'Updates',
// },
'/portal/system/sideload': { '/portal/system/sideload': {
icon: '@tui.upload', icon: '@tui.upload',
title: 'Sideload', title: 'Sideload',
}, },
'/portal/system/logs': { // '/portal/system/updates': {
icon: '@tui.file-text', // icon: '@tui.globe',
title: 'Logs', // title: 'Updates',
// },
'/portal/system/backups': {
icon: '@tui.save',
title: 'Backups',
}, },
'/portal/system/metrics': { '/portal/system/metrics': {
icon: '@tui.activity', icon: '@tui.activity',
title: 'Metrics', title: 'Metrics',
}, },
'/portal/system/backups': { '/portal/system/logs': {
icon: '@tui.save', icon: '@tui.file-text',
title: 'Backups', title: 'Logs',
}, },
'/portal/system/settings': { '/portal/system/settings': {
icon: '@tui.wrench', icon: '@tui.wrench',
title: 'Settings', title: 'Settings',
}, },
'/portal/system/notifications': {
icon: '@tui.bell',
title: 'Notifications',
},
} }
export function getMenu() { export function getMenu() {

View File

@@ -1,3 +1,3 @@
export function toRouterLink(id: string): string { export function toRouterLink(id: string): string {
return id.includes('/') ? id : `/portal/service/${id}` return id.includes('/') ? id : `/portal/services/${id}`
} }

View File

@@ -85,10 +85,6 @@ hr {
background: var(--tui-background-neutral-1); background: var(--tui-background-neutral-1);
font-weight: bold; font-weight: bold;
} }
.tui-skeleton {
max-height: 0.5rem;
}
} }
tui-root._mobile .g-table { tui-root._mobile .g-table {