diff --git a/frontend/projects/shared/assets/img/background_dark.jpeg b/frontend/projects/shared/assets/img/background_dark.jpeg
new file mode 100644
index 000000000..59ed7d7d7
Binary files /dev/null and b/frontend/projects/shared/assets/img/background_dark.jpeg differ
diff --git a/frontend/projects/shared/assets/img/background_light.jpeg b/frontend/projects/shared/assets/img/background_light.jpeg
new file mode 100644
index 000000000..0795145f9
Binary files /dev/null and b/frontend/projects/shared/assets/img/background_light.jpeg differ
diff --git a/frontend/projects/shared/styles/taiga.scss b/frontend/projects/shared/styles/taiga.scss
index 8bd35d622..3fbc0b9bc 100644
--- a/frontend/projects/shared/styles/taiga.scss
+++ b/frontend/projects/shared/styles/taiga.scss
@@ -27,6 +27,18 @@
}
}
+[tuiWrapper][data-appearance='success'] {
+ color: var(--tui-success-fill);
+}
+
+[tuiWrapper][data-appearance='warning'] {
+ color: var(--tui-warning-fill);
+}
+
+[tuiWrapper][data-appearance='error'] {
+ color: var(--tui-error-fill);
+}
+
tui-dialog {
transform: translate3d(0, 0, 0);
}
diff --git a/frontend/projects/ui/src/app/app.component.html b/frontend/projects/ui/src/app/app.component.html
index dcc65b8c6..78ac210ca 100644
--- a/frontend/projects/ui/src/app/app.component.html
+++ b/frontend/projects/ui/src/app/app.component.html
@@ -67,7 +67,11 @@
diff --git a/frontend/projects/ui/src/app/app.component.ts b/frontend/projects/ui/src/app/app.component.ts
index 68e77ffee..c8b2fcb5f 100644
--- a/frontend/projects/ui/src/app/app.component.ts
+++ b/frontend/projects/ui/src/app/app.component.ts
@@ -17,7 +17,11 @@ import { PatchDB } from 'patch-db-client'
import { DataModel } from './services/patch-db/data-model'
function hasNavigation(url: string): boolean {
- return !url.startsWith('/loading') && !url.startsWith('/diagnostic')
+ return (
+ !url.startsWith('/loading') &&
+ !url.startsWith('/diagnostic') &&
+ !url.startsWith('/portal')
+ )
}
@Component({
diff --git a/frontend/projects/ui/src/app/apps/portal/components/header/header.component.html b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.html
new file mode 100644
index 000000000..54abed9a7
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/apps/portal/components/header/header.component.scss b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.scss
new file mode 100644
index 000000000..85ded7f11
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.scss
@@ -0,0 +1,12 @@
+:host {
+ display: flex;
+ align-items: center;
+ height: 4.5rem;
+ background: rgb(51 51 51 / 74%);
+ padding: 0 1rem 0 2rem;
+ font-size: 1.5rem;
+}
+
+.toolbar {
+ margin-left: auto;
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/components/header/header.component.ts b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.ts
new file mode 100644
index 000000000..8e84d8f47
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/header/header.component.ts
@@ -0,0 +1,13 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core'
+import { TuiBadgedContentModule } from '@taiga-ui/kit'
+import { TuiButtonModule } from '@taiga-ui/core'
+
+@Component({
+ selector: 'header[appHeader]',
+ templateUrl: 'header.component.html',
+ styleUrls: ['header.component.scss'],
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [TuiBadgedContentModule, TuiButtonModule],
+})
+export class HeaderComponent {}
diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html
new file mode 100644
index 000000000..baa36ccd1
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.scss b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.scss
new file mode 100644
index 000000000..2e2834f3a
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.scss
@@ -0,0 +1,29 @@
+:host {
+ height: 3rem;
+ display: flex;
+ background: rgb(97 95 95 / 75%);
+}
+
+.tab {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 7.5rem;
+
+ &_active {
+ background: #373a3f;
+ }
+}
+
+.icon {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 100%;
+}
+
+.close {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.ts b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.ts
new file mode 100644
index 000000000..7c9655020
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.ts
@@ -0,0 +1,26 @@
+import { CommonModule, Location } from '@angular/common'
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
+import { RouterModule } from '@angular/router'
+import { TuiButtonModule } from '@taiga-ui/core'
+import { NavigationItem, NavigationService } from './navigation.service'
+
+@Component({
+ selector: 'nav[appNavigation]',
+ templateUrl: 'navigation.component.html',
+ styleUrls: ['navigation.component.scss'],
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [CommonModule, RouterModule, TuiButtonModule],
+})
+export class NavigationComponent {
+ private readonly location = inject(Location)
+ private readonly navigation = inject(NavigationService)
+
+ readonly tabs$ = this.navigation.getTabs()
+
+ removeTab(tab: NavigationItem, active: boolean) {
+ this.navigation.removeTab(tab)
+
+ if (active) this.location.back()
+ }
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts
new file mode 100644
index 000000000..232634697
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts
@@ -0,0 +1,29 @@
+import { Injectable } from '@angular/core'
+import { BehaviorSubject, Observable } from 'rxjs'
+
+export interface NavigationItem {
+ readonly routerLink: string
+ readonly icon: string
+ readonly title: string
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class NavigationService {
+ readonly tabs = new BehaviorSubject([])
+
+ getTabs(): Observable {
+ return this.tabs
+ }
+
+ removeTab(tab: NavigationItem) {
+ this.tabs.next(this.tabs.value.filter(t => t !== tab))
+ }
+
+ addTab(tab: NavigationItem) {
+ if (this.tabs.value.every(t => t.routerLink !== tab.routerLink)) {
+ this.tabs.next([...this.tabs.value, tab])
+ }
+ }
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/portal.component.html b/frontend/projects/ui/src/app/apps/portal/portal.component.html
new file mode 100644
index 000000000..5eb48eb41
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/portal.component.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/apps/portal/portal.component.scss b/frontend/projects/ui/src/app/apps/portal/portal.component.scss
new file mode 100644
index 000000000..128c72f8a
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/portal.component.scss
@@ -0,0 +1,8 @@
+:host {
+ background: url(/assets/img/background_dark.jpeg);
+ background-size: cover;
+}
+
+main {
+ flex: 1;
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/portal.component.ts b/frontend/projects/ui/src/app/apps/portal/portal.component.ts
new file mode 100644
index 000000000..57d53a6fe
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/portal.component.ts
@@ -0,0 +1,8 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core'
+
+@Component({
+ templateUrl: 'portal.component.html',
+ styleUrls: ['portal.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PortalComponent {}
diff --git a/frontend/projects/ui/src/app/apps/portal/portal.module.ts b/frontend/projects/ui/src/app/apps/portal/portal.module.ts
new file mode 100644
index 000000000..5512c0854
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/portal.module.ts
@@ -0,0 +1,37 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { HeaderComponent } from './components/header/header.component'
+import { PortalComponent } from './portal.component'
+import { NavigationComponent } from './components/navigation/navigation.component'
+
+const ROUTES: Routes = [
+ {
+ path: '',
+ component: PortalComponent,
+ children: [
+ {
+ redirectTo: 'services',
+ pathMatch: 'full',
+ path: '',
+ },
+ {
+ path: 'services',
+ loadChildren: () =>
+ import('./routes/services/services.module').then(
+ m => m.ServicesModule,
+ ),
+ },
+ ],
+ },
+]
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(ROUTES),
+ HeaderComponent,
+ NavigationComponent,
+ ],
+ declarations: [PortalComponent],
+ exports: [PortalComponent],
+})
+export class PortalModule {}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.html b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.html
new file mode 100644
index 000000000..044b180cf
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.html
@@ -0,0 +1 @@
+{{ (service$ | async)?.manifest?.title }}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.ts
new file mode 100644
index 000000000..380573d1a
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.component.ts
@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { getPkgId } from '@start9labs/shared'
+import { PatchDB } from 'patch-db-client'
+import { tap } from 'rxjs'
+import { DataModel } from 'src/app/services/patch-db/data-model'
+import { NavigationService } from '../../../components/navigation/navigation.service'
+
+@Component({
+ templateUrl: 'service.component.html',
+ styleUrls: ['service.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ServiceComponent {
+ private readonly route = inject(ActivatedRoute)
+ private readonly router = inject(Router)
+ private readonly navigation = inject(NavigationService)
+ private readonly patch = inject>(PatchDB)
+
+ readonly service$ = this.patch
+ .watch$('package-data', getPkgId(this.route))
+ .pipe(
+ tap(pkg => {
+ // if package disappears, navigate to list page
+ if (!pkg) {
+ this.router.navigate(['..'], { relativeTo: this.route })
+ } else {
+ this.navigation.addTab({
+ title: pkg.manifest.title,
+ routerLink: ['services', pkg.manifest.id].join('/'),
+ icon: pkg.icon,
+ })
+ }
+ }),
+ )
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.module.ts
new file mode 100644
index 000000000..4d187310b
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/service/service.module.ts
@@ -0,0 +1,18 @@
+import { CommonModule } from '@angular/common'
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { ServiceComponent } from './service.component'
+
+const ROUTES: Routes = [
+ {
+ path: '',
+ component: ServiceComponent,
+ },
+]
+
+@NgModule({
+ imports: [CommonModule, RouterModule.forChild(ROUTES)],
+ declarations: [ServiceComponent],
+ exports: [ServiceComponent],
+})
+export class ServiceModule {}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.html b/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.html
new file mode 100644
index 000000000..51dcfae2a
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.html
@@ -0,0 +1,7 @@
+
+ {{ service.manifest.title }}
+
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.ts
new file mode 100644
index 000000000..bd9e8749b
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/services.component.ts
@@ -0,0 +1,23 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
+import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
+import { NavigationService } from '../../components/navigation/navigation.service'
+import { ServicesService } from './services.service'
+
+@Component({
+ templateUrl: 'services.component.html',
+ styleUrls: ['services.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ServicesComponent {
+ private readonly navigation = inject(NavigationService)
+
+ readonly services$ = inject(ServicesService)
+
+ onClick({ manifest, icon }: PackageDataEntry) {
+ this.navigation.addTab({
+ title: manifest.title,
+ routerLink: ['services', manifest.id].join('/'),
+ icon,
+ })
+ }
+}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts
new file mode 100644
index 000000000..82488da4a
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts
@@ -0,0 +1,23 @@
+import { CommonModule } from '@angular/common'
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { ServicesComponent } from './services.component'
+
+const ROUTES: Routes = [
+ {
+ path: '',
+ component: ServicesComponent,
+ },
+ {
+ path: ':pkgId',
+ loadChildren: () =>
+ import('./service/service.module').then(m => m.ServiceModule),
+ },
+]
+
+@NgModule({
+ imports: [CommonModule, RouterModule.forChild(ROUTES)],
+ declarations: [ServicesComponent],
+ exports: [ServicesComponent],
+})
+export class ServicesModule {}
diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.service.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/services.service.ts
new file mode 100644
index 000000000..0a0683fbc
--- /dev/null
+++ b/frontend/projects/ui/src/app/apps/portal/routes/services/services.service.ts
@@ -0,0 +1,36 @@
+import { inject, Injectable } from '@angular/core'
+import { PatchDB } from 'patch-db-client'
+import { filter, map, Observable, pairwise, shareReplay, startWith } from 'rxjs'
+import {
+ DataModel,
+ PackageDataEntry,
+} from 'src/app/services/patch-db/data-model'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class ServicesService extends Observable {
+ private readonly services$ = inject>(PatchDB)
+ .watch$('package-data')
+ .pipe(
+ map(pkgs => Object.values(pkgs)),
+ startWith([]),
+ pairwise(),
+ filter(([prev, next]) => {
+ const length = next.length
+ return !length || prev.length !== length
+ }),
+ map(([_, pkgs]) =>
+ pkgs.sort((a, b) =>
+ b.manifest.title.toLowerCase() > a.manifest.title.toLowerCase()
+ ? -1
+ : 1,
+ ),
+ ),
+ shareReplay(1),
+ )
+
+ constructor() {
+ super(subscriber => this.services$.subscribe(subscriber))
+ }
+}
diff --git a/frontend/projects/ui/src/app/common/toast-container/refresh-alert/refresh-alert.component.html b/frontend/projects/ui/src/app/common/toast-container/refresh-alert/refresh-alert.component.html
index db2480687..cb412aae9 100644
--- a/frontend/projects/ui/src/app/common/toast-container/refresh-alert/refresh-alert.component.html
+++ b/frontend/projects/ui/src/app/common/toast-container/refresh-alert/refresh-alert.component.html
@@ -1,27 +1,27 @@
-
- Your user interface is cached and out of date. Hard refresh the page to get
- the latest UI.
-
- -
- On Mac
- : cmd + shift + R
-
- -
- On Linux/Windows
- : ctrl + shift + R
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/routing.module.ts b/frontend/projects/ui/src/app/routing.module.ts
index f8b67c9f9..f713d6fa8 100644
--- a/frontend/projects/ui/src/app/routing.module.ts
+++ b/frontend/projects/ui/src/app/routing.module.ts
@@ -22,6 +22,13 @@ const routes: Routes = [
loadChildren: () =>
import('./apps/login/login.module').then(m => m.LoginPageModule),
},
+ {
+ path: 'portal',
+ canActivate: [AuthGuard],
+ canActivateChild: [AuthGuard],
+ loadChildren: () =>
+ import('./apps/portal/portal.module').then(m => m.PortalModule),
+ },
{
path: '',
canActivate: [AuthGuard],