mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
feat(portal): refactor marketplace for new portal (#2539)
* feat(portal): refactor marketplace for new portal * fix background position * chore: refactor sidebar * chore: small fix --------- Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<header
|
||||
class="z-50 overflow-hidden w-full fixed sm:w-[34vw] md:w-[28vw] lg:w-[22vw] 2xl:w-[280px] sm:bg-zinc-700/90 sm:backdrop-blur-2xl sm:min-h-screen overflow-y-auto flex flex-col sm:py-6 sm:after:block sm:after:absolute sm:after:top-0 sm:after:bottom-0 sm:after:right-0 sm:after:w-0.5 after:h-[calc(100vh - 20px)] sm:after:bg-gradient-to-b from-zinc-700 to-zinc-400"
|
||||
class="header overflow-hidden w-full fixed sm:w-[34vw] md:w-[28vw] lg:w-[22vw] 2xl:w-[280px] sm:bg-zinc-700/90 sm:backdrop-blur-2xl sm:min-h-screen overflow-y-auto flex flex-col sm:py-6 sm:after:block sm:after:absolute sm:after:top-0 sm:after:bottom-0 sm:after:right-0 sm:after:w-0.5 after:h-[calc(100vh - 20px)] sm:after:bg-gradient-to-b from-zinc-700 to-zinc-400"
|
||||
>
|
||||
<ng-container *tuiLet="store$ | async as store">
|
||||
<div class="hidden sm:flex flex-col mx-6 pb-3 items-center">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.header {
|
||||
@include scrollbar-hidden();
|
||||
|
||||
max-height: 100%;
|
||||
// TODO: Theme
|
||||
background: #373a3f;
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { combineLatest, map, Subject, takeUntil } from 'rxjs'
|
||||
import { StoreIdentity } from '../../types'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
import { AbstractCategoryService } from '../../services/category.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { MarketplaceConfig } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
@@ -22,8 +21,6 @@ export class MenuComponent implements OnDestroy {
|
||||
@Input({ required: true })
|
||||
iconConfig!: MarketplaceConfig
|
||||
|
||||
constructor(private readonly router: Router) {}
|
||||
|
||||
private destroy$ = new Subject<void>()
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
private readonly categoryService = inject(AbstractCategoryService)
|
||||
@@ -69,13 +66,11 @@ export class MenuComponent implements OnDestroy {
|
||||
this.query = ''
|
||||
this.categoryService.resetQuery()
|
||||
this.categoryService.changeCategory(category)
|
||||
this.router.navigate(['/marketplace'], { replaceUrl: true })
|
||||
}
|
||||
|
||||
onQueryChange(query: string): void {
|
||||
this.query = query
|
||||
this.categoryService.setQuery(query)
|
||||
this.router.navigate(['/marketplace'], { replaceUrl: true })
|
||||
}
|
||||
|
||||
toggleMenu(open: boolean): void {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:root {
|
||||
--tui-primary: #3880ff;
|
||||
--tui-primary-hover: #4c8dff;
|
||||
--tui-primary-active: #3171e0;
|
||||
}
|
||||
|
||||
/* stylelint-disable order/order */
|
||||
[tuiAppearance][data-appearance='secondary-warning'] {
|
||||
background: var(--tui-warning-bg);
|
||||
color: var(--tui-warning-fill);
|
||||
|
||||
@include wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: var(--tui-warning-bg-hover);
|
||||
}
|
||||
|
||||
@include wrapper-active {
|
||||
@include appearance-active {
|
||||
background: var(--tui-warning-bg-hover);
|
||||
}
|
||||
}
|
||||
@@ -30,142 +36,96 @@
|
||||
[tuiAppearance][data-appearance='outline'] {
|
||||
color: var(--tui-text-01);
|
||||
}
|
||||
[tuiWrapper][data-appearance='primary-solid'] {
|
||||
background: #3880ff;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
background: #4c8dff;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
background: #3171e0;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
[tuiAppearance][data-appearance='primary'] {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='secondary-solid'] {
|
||||
[tuiAppearance][data-appearance='secondary-solid'] {
|
||||
background: #3dc2ff;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: #50c8ff;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
@include appearance-active {
|
||||
background: #36abe0;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='tertiary-solid'] {
|
||||
[tuiAppearance][data-appearance='tertiary-solid'] {
|
||||
background: #5260ff;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: #6370ff;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
@include appearance-active {
|
||||
background: #4854e0;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='success-solid'] {
|
||||
[tuiAppearance][data-appearance='success-solid'] {
|
||||
background: #2dd36f;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: #42d77d;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
@include appearance-active {
|
||||
background: #28ba62;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='warning-solid'] {
|
||||
[tuiAppearance][data-appearance='warning-solid'] {
|
||||
background: #ffc409;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: #ffca22;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
@include appearance-active {
|
||||
background: #e0ac08;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='danger-solid'] {
|
||||
[tuiAppearance][data-appearance='danger-solid'] {
|
||||
background: #eb445a;
|
||||
color: #fff;
|
||||
|
||||
.wrapper-hover {
|
||||
@include appearance-hover {
|
||||
background: #ed576b;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-active {
|
||||
@include appearance-active {
|
||||
background: #cf3c4f;
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
.wrapper-disabled {
|
||||
@include appearance-disabled {
|
||||
background: #eaecee;
|
||||
}
|
||||
|
||||
;
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='input-file'] {
|
||||
@@ -196,7 +156,7 @@ tui-hint[data-appearance='onDark'] {
|
||||
}
|
||||
}
|
||||
|
||||
[tuiWrapper][data-appearance='drawer'] {
|
||||
[tuiAppearance][data-appearance='drawer'] {
|
||||
// TODO: Theme
|
||||
background: rgb(81 80 83 / 86%);
|
||||
border-radius: 10rem;
|
||||
@@ -215,7 +175,9 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
|
||||
tui-opt-group {
|
||||
&::before {
|
||||
background: var(--tui-clear);
|
||||
box-shadow: 1rem 0 var(--tui-clear), -1rem 0 var(--tui-clear);
|
||||
box-shadow:
|
||||
1rem 0 var(--tui-clear),
|
||||
-1rem 0 var(--tui-clear);
|
||||
padding-top: 0.375rem !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<tui-badge-notification *ngIf="badge" size="m" tuiSlot="top">
|
||||
{{ badge }}
|
||||
</tui-badge-notification>
|
||||
<tui-svg
|
||||
<tui-icon
|
||||
*ngIf="icon?.startsWith('tuiIcon'); else url"
|
||||
class="icon"
|
||||
[src]="icon"
|
||||
></tui-svg>
|
||||
[icon]="icon"
|
||||
/>
|
||||
<ng-template #url>
|
||||
<img alt="" class="icon" [src]="icon" />
|
||||
</ng-template>
|
||||
|
||||
@@ -10,14 +10,11 @@ import {
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TickerModule } from '@start9labs/shared'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core'
|
||||
import { NavigationService } from '../../services/navigation.service'
|
||||
import { Action, ActionsComponent } from '../actions/actions.component'
|
||||
import { toRouterLink } from '../../utils/to-router-link'
|
||||
@@ -34,7 +31,7 @@ import { toRouterLink } from '../../utils/to-router-link'
|
||||
TuiButtonModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
TuiIconModule,
|
||||
TickerModule,
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
routerLinkActive="tab_active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
>
|
||||
<tui-svg src="tuiIconHomeLarge" class="icon"></tui-svg>
|
||||
<tui-icon icon="tuiIconHome" class="icon" />
|
||||
</a>
|
||||
<a
|
||||
*ngFor="let tab of tabs$ | async"
|
||||
@@ -13,11 +13,11 @@
|
||||
routerLinkActive="tab_active"
|
||||
[routerLink]="tab.routerLink"
|
||||
>
|
||||
<tui-svg
|
||||
<tui-icon
|
||||
*ngIf="tab.icon.startsWith('tuiIcon'); else url"
|
||||
class="icon"
|
||||
[src]="tab.icon"
|
||||
></tui-svg>
|
||||
[icon]="tab.icon"
|
||||
/>
|
||||
<ng-template #url>
|
||||
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
|
||||
</ng-template>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { NavigationService } from '../../services/navigation.service'
|
||||
import { NavigationItem } from '../../types/navigation-item'
|
||||
|
||||
@Component({
|
||||
selector: 'nav[appNavigation]',
|
||||
@@ -12,7 +10,7 @@ import { NavigationItem } from '../../types/navigation-item'
|
||||
styleUrls: ['navigation.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterModule, TuiButtonModule, TuiSvgModule],
|
||||
imports: [CommonModule, RouterModule, TuiButtonModule, TuiIconModule],
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private readonly router = inject(Router)
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
{
|
||||
'/portal/system/backups': {
|
||||
icon: 'tuiIconSaveLarge',
|
||||
icon: 'tuiIconSave',
|
||||
title: 'Backups',
|
||||
},
|
||||
'/portal/system/marketplace': {
|
||||
icon: 'tuiIconShoppingCart',
|
||||
title: 'Marketplace',
|
||||
},
|
||||
'/portal/system/updates': {
|
||||
icon: 'tuiIconGlobeLarge',
|
||||
icon: 'tuiIconGlobe',
|
||||
title: 'Updates',
|
||||
},
|
||||
'/portal/system/sideload': {
|
||||
icon: 'tuiIconUploadLarge',
|
||||
icon: 'tuiIconUpload',
|
||||
title: 'Sideload',
|
||||
},
|
||||
'/portal/system/settings': {
|
||||
icon: 'tuiIconToolLarge',
|
||||
icon: 'tuiIconTool',
|
||||
title: 'Settings',
|
||||
},
|
||||
'/portal/system/snek': {
|
||||
@@ -21,7 +25,7 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
title: 'Snek',
|
||||
},
|
||||
'/portal/system/notifications': {
|
||||
icon: 'tuiIconBellLarge',
|
||||
icon: 'tuiIconBell',
|
||||
title: 'Notifications',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ToMenuPipe } from '../pipes/to-menu.pipe'
|
||||
import { ServiceMenuItemComponent } from './menu-item.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'service-menu',
|
||||
template: `
|
||||
<h3 class="g-title">Menu</h3>
|
||||
<button
|
||||
*ngFor="let menu of service | toMenu"
|
||||
class="g-action"
|
||||
[serviceMenuItem]="menu"
|
||||
(click)="menu.action()"
|
||||
>
|
||||
<div *ngIf="menu.name === 'Outbound Proxy'" [style.color]="color">
|
||||
{{ proxy }}
|
||||
</div>
|
||||
</button>
|
||||
@for (menu of service | toMenu; track $index) {
|
||||
@if (menu.routerLink) {
|
||||
<a
|
||||
class="g-action"
|
||||
[serviceMenuItem]="menu"
|
||||
[routerLink]="menu.routerLink"
|
||||
[queryParams]="menu.params || {}"
|
||||
></a>
|
||||
} @else {
|
||||
<button
|
||||
class="g-action"
|
||||
[serviceMenuItem]="menu"
|
||||
(click)="menu.action?.()"
|
||||
>
|
||||
@if (menu.name === 'Outbound Proxy') {
|
||||
<div [style.color]="color">{{ proxy }}</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ToMenuPipe, ServiceMenuItemComponent],
|
||||
imports: [ToMenuPipe, ServiceMenuItemComponent, RouterLink],
|
||||
})
|
||||
export class ServiceMenuComponent {
|
||||
@Input({ required: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { inject, Pipe, PipeTransform, Type } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { ActivatedRoute, Params, Router } from '@angular/router'
|
||||
import { Manifest } from '@start9labs/marketplace'
|
||||
import { MarkdownComponent } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
@@ -20,7 +20,9 @@ export interface ServiceMenu {
|
||||
icon: string
|
||||
name: string
|
||||
description: string
|
||||
action: () => void
|
||||
action?: () => void
|
||||
routerLink?: string
|
||||
params?: Params
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
@@ -66,10 +68,7 @@ export class ToMenuPipe implements PipeTransform {
|
||||
icon: 'tuiIconZapLarge',
|
||||
name: 'Actions',
|
||||
description: `Uninstall and other commands specific to ${manifest.title}`,
|
||||
action: () =>
|
||||
this.router.navigate(['actions'], {
|
||||
relativeTo: this.route,
|
||||
}),
|
||||
routerLink: `actions`,
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconShieldLarge',
|
||||
@@ -81,27 +80,20 @@ export class ToMenuPipe implements PipeTransform {
|
||||
icon: 'tuiIconFileTextLarge',
|
||||
name: 'Logs',
|
||||
description: `Raw, unfiltered logs`,
|
||||
action: () =>
|
||||
this.router.navigate(['logs'], {
|
||||
relativeTo: this.route,
|
||||
}),
|
||||
routerLink: 'logs',
|
||||
},
|
||||
url
|
||||
? {
|
||||
icon: 'tuiIconShoppingBagLarge',
|
||||
name: 'Marketplace Listing',
|
||||
description: `View ${manifest.title} on the Marketplace`,
|
||||
action: () =>
|
||||
this.router.navigate(['marketplace'], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { url, id: manifest.id },
|
||||
}),
|
||||
routerLink: `/portal/system/marketplace`,
|
||||
params: { url, id: manifest.id },
|
||||
}
|
||||
: {
|
||||
icon: 'tuiIconShoppingBagLarge',
|
||||
name: 'Marketplace Listing',
|
||||
description: `This package was not installed from the marketplace`,
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
MarketplacePkg,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
Emver,
|
||||
ErrorService,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
EmverPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { getAllPackages } from 'src/app/util/get-package-data'
|
||||
import { dryUpdate } from 'src/app/util/dry-update'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
import { MarketplaceAlertsService } from '../services/alerts.service'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-controls',
|
||||
template: `
|
||||
@if (localPkg) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="primary"
|
||||
(click)="showService()"
|
||||
>
|
||||
View Installed
|
||||
</button>
|
||||
@if (installed) {
|
||||
@switch (localVersion | compareEmver: pkg.manifest.version) {
|
||||
@case (1) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary-solid"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Downgrade
|
||||
</button>
|
||||
}
|
||||
@case (-1) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="warning-solid"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
}
|
||||
@case (0) {
|
||||
@if (showDevTools$ | async) {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="tertiary-solid"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Reinstall
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="primary"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, EmverPipesModule, TuiButtonModule],
|
||||
})
|
||||
export class MarketplaceControlsComponent {
|
||||
private readonly alerts = inject(MarketplaceAlertsService)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly emver = inject(Emver)
|
||||
private readonly router = inject(Router)
|
||||
private readonly marketplace = inject(
|
||||
AbstractMarketplaceService,
|
||||
) as MarketplaceService
|
||||
|
||||
@Input()
|
||||
url?: string
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
@Input()
|
||||
localPkg!: PackageDataEntry | null
|
||||
|
||||
readonly showDevTools$ = inject(ClientStorageService).showDevTools$
|
||||
|
||||
get installed(): boolean {
|
||||
return this.localPkg?.state === PackageState.Installed
|
||||
}
|
||||
|
||||
get localVersion(): string {
|
||||
return this.localPkg?.manifest.version || ''
|
||||
}
|
||||
|
||||
async tryInstall() {
|
||||
const current = await firstValueFrom(this.marketplace.getSelectedHost$())
|
||||
const url = this.url || current.url
|
||||
const originalUrl = this.localPkg?.installed?.['marketplace-url'] || ''
|
||||
|
||||
if (!this.localPkg) {
|
||||
if (await this.alerts.alertInstall(this.pkg)) this.install(url)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!sameUrl(url, originalUrl) &&
|
||||
!(await this.alerts.alertMarketplace(url, originalUrl))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
hasCurrentDeps(this.localPkg) &&
|
||||
this.emver.compare(this.localVersion, this.pkg.manifest.version) !== 0
|
||||
) {
|
||||
this.dryInstall(url)
|
||||
} else {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
|
||||
async showService() {
|
||||
this.router.navigate(['/portal/service', this.pkg.manifest.id])
|
||||
}
|
||||
|
||||
private async dryInstall(url: string) {
|
||||
const breakages = dryUpdate(
|
||||
this.pkg.manifest,
|
||||
await getAllPackages(this.patch),
|
||||
this.emver,
|
||||
)
|
||||
|
||||
if (
|
||||
isEmptyObject(breakages) ||
|
||||
(await this.alerts.alertBreakages(breakages))
|
||||
) {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
|
||||
private async install(url: string) {
|
||||
const loader = this.loader.open('Beginning Install...').subscribe()
|
||||
const { id, version } = this.pkg.manifest
|
||||
|
||||
try {
|
||||
await this.marketplace.installPackage(id, version, url)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { MenuModule } from '@start9labs/marketplace'
|
||||
import { TuiButtonModule, TuiDialogService } from '@taiga-ui/core'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { TuiAppearanceModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { MARKETPLACE_REGISTRY } from '../modals/registry.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'marketplace-menu',
|
||||
template: `
|
||||
<menu [iconConfig]="marketplace">
|
||||
<button
|
||||
slot="desktop"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
icon="tuiIconRepeat"
|
||||
(click)="changeRegistry()"
|
||||
>
|
||||
Change Registry
|
||||
</button>
|
||||
<button
|
||||
slot="mobile"
|
||||
class="flex gap-2 p-5 text-base"
|
||||
(click)="changeRegistry()"
|
||||
>
|
||||
<tui-icon tuiAppearance="icon" icon="tuiIconRepeat"></tui-icon>
|
||||
Change Registry
|
||||
</button>
|
||||
</menu>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MenuModule, TuiButtonModule, TuiIconModule, TuiAppearanceModule],
|
||||
})
|
||||
export class MarketplaceMenuComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
readonly marketplace = inject(ConfigService).marketplace
|
||||
|
||||
changeRegistry() {
|
||||
this.dialogs
|
||||
.open(MARKETPLACE_REGISTRY, {
|
||||
label: 'Change Registry',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, inject, Input } from '@angular/core'
|
||||
import { NgIf } from '@angular/common'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'marketplace-notification',
|
||||
template: `
|
||||
<tui-notification [status]="status || 'warning'" icon="" class="m-4">
|
||||
@switch (status) {
|
||||
@case ('success') {
|
||||
Services from this registry are packaged and maintained by the Start9
|
||||
team. If you experience an issue or have questions related to a
|
||||
service from this registry, one of our dedicated support staff will be
|
||||
happy to assist you.
|
||||
}
|
||||
@case ('info') {
|
||||
Services from this registry are packaged and maintained by members of
|
||||
the Start9 community.
|
||||
<strong>Install at your own risk</strong>
|
||||
. If you experience an issue or have a question related to a service
|
||||
in this marketplace, please reach out to the package developer for
|
||||
assistance.
|
||||
}
|
||||
@case ('warning') {
|
||||
Services from this registry are undergoing
|
||||
<strong>beta</strong>
|
||||
testing and may contain bugs.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
}
|
||||
@case ('error') {
|
||||
Services from this registry are undergoing
|
||||
<strong>alpha</strong>
|
||||
testing. They are expected to contain bugs and could damage your
|
||||
system.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
}
|
||||
@default {
|
||||
This is a Custom Registry. Start9 cannot verify the integrity or
|
||||
functionality of services from this registry, and they could damage
|
||||
your system.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
}
|
||||
}
|
||||
</tui-notification>
|
||||
`,
|
||||
imports: [TuiNotificationModule],
|
||||
})
|
||||
export class MarketplaceNotificationComponent {
|
||||
private readonly marketplace = inject(ConfigService).marketplace
|
||||
|
||||
@Input() url = ''
|
||||
|
||||
get status() {
|
||||
if (this.url === this.marketplace.start9) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (this.url === this.marketplace.community) {
|
||||
return 'info'
|
||||
}
|
||||
|
||||
if (this.url.includes('beta')) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
if (this.url.includes('alpha')) {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { StoreIconComponentModule } from '@start9labs/marketplace'
|
||||
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: '[registry]',
|
||||
template: `
|
||||
<store-icon [url]="registry.url" [marketplace]="marketplace" size="40px" />
|
||||
<div tuiTitle>
|
||||
{{ registry.name }}
|
||||
<div tuiSubtitle>{{ registry.url }}</div>
|
||||
</div>
|
||||
<tui-icon
|
||||
*ngIf="registry.selected; else content"
|
||||
icon="tuiIconCheck"
|
||||
[style.color]="'var(--tui-positive)'"
|
||||
/>
|
||||
<ng-template #content><ng-content></ng-content></ng-template>
|
||||
`,
|
||||
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, StoreIconComponentModule, TuiIconModule, TuiTitleModule],
|
||||
})
|
||||
export class MarketplaceRegistryComponent {
|
||||
readonly marketplace = inject(ConfigService).marketplace
|
||||
|
||||
@Input()
|
||||
registry!: { url: string; selected: boolean; name?: string }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import {
|
||||
AbstractTuiPortalHostComponent,
|
||||
AbstractTuiPortalService,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { MarketplaceSidebarService } from '../services/sidebar.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'marketplace-sidebars',
|
||||
template: '<ng-container #viewContainer></ng-container>',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 7.5rem 0 0;
|
||||
pointer-events: none;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: AbstractTuiPortalService,
|
||||
useExisting: MarketplaceSidebarService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MarketplaceSidebarsComponent extends AbstractTuiPortalHostComponent {}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { ItemModule, MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
|
||||
import {
|
||||
TuiActiveZoneModule,
|
||||
TuiAutoFocusModule,
|
||||
TuiDropdownPortalService,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { debounceTime, map } from 'rxjs'
|
||||
import { ToLocalPipe } from '../pipes/to-local.pipe'
|
||||
import { MarketplaceControlsComponent } from './controls.component'
|
||||
import { MarketplacePreviewComponent } from '../modals/preview.component'
|
||||
import { MarketplaceSidebarService } from '../services/sidebar.service'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-tile',
|
||||
template: `
|
||||
<marketplace-item [pkg]="pkg" (click)="toggle(true)">
|
||||
<marketplace-preview
|
||||
*tuiSidebar="
|
||||
(id$ | async) === pkg.manifest.id;
|
||||
direction: 'right';
|
||||
autoWidth: true
|
||||
"
|
||||
class="overflow-y-auto max-w-full md:max-w-[30rem]"
|
||||
[pkg]="pkg"
|
||||
(tuiActiveZoneChange)="toggle($event)"
|
||||
>
|
||||
<button
|
||||
tuiAutoFocus
|
||||
slot="close"
|
||||
size="xs"
|
||||
class="place-self-end"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconClose"
|
||||
(click)="toggle(false)"
|
||||
></button>
|
||||
<marketplace-controls
|
||||
slot="controls"
|
||||
class="flex justify-start gap-2"
|
||||
[pkg]="pkg"
|
||||
[localPkg]="pkg.manifest.id | toLocal | async"
|
||||
/>
|
||||
</marketplace-preview>
|
||||
</marketplace-item>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
animation: animateIn 400ms calc(var(--animation-order) * 200ms) both;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
providers: [
|
||||
{
|
||||
provide: TuiDropdownPortalService,
|
||||
useExisting: MarketplaceSidebarService,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ItemModule,
|
||||
ToLocalPipe,
|
||||
TuiActiveZoneModule,
|
||||
TuiSidebarModule,
|
||||
TuiButtonModule,
|
||||
MarketplaceControlsComponent,
|
||||
MarketplacePreviewComponent,
|
||||
TuiAutoFocusModule,
|
||||
],
|
||||
})
|
||||
export class MarketplaceTileComponent {
|
||||
private readonly router = inject(Router)
|
||||
|
||||
readonly id$ = inject(ActivatedRoute).queryParamMap.pipe(
|
||||
map(map => map.get('id') || ''),
|
||||
debounceTime(100),
|
||||
)
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
toggle(open: boolean) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { id: open ? this.pkg.manifest.id : null },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
AbstractCategoryService,
|
||||
AbstractMarketplaceService,
|
||||
FilterPackagesPipe,
|
||||
} from '@start9labs/marketplace'
|
||||
import { combineLatest, map } from 'rxjs'
|
||||
import { MarketplaceNotificationComponent } from './components/notification.component'
|
||||
import { MarketplaceMenuComponent } from './components/menu.component'
|
||||
import { MarketplaceTileComponent } from './components/tile.component'
|
||||
import { MarketplaceControlsComponent } from './components/controls.component'
|
||||
import { MarketplacePreviewComponent } from './modals/preview.component'
|
||||
import { MarketplaceSidebarsComponent } from './components/sidebars.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<marketplace-menu />
|
||||
<div
|
||||
class="sm:pl-[34vw] md:pl-[28vw] lg:pl-[22vw] 2xl:pl-[280px] min-h-screen flex justify-between overflow-auto scroll-smooth"
|
||||
>
|
||||
<div class="pt-24 sm:pt-3 md:pb-10 md:px-8">
|
||||
<marketplace-notification [url]="(details$ | async)?.url || ''" />
|
||||
<div class="mt-8 px-6 mb-10">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-zinc-50/80">
|
||||
{{ category$ | async | titlecase }}
|
||||
</h1>
|
||||
</div>
|
||||
@if (filtered$ | async; as filtered) {
|
||||
<section
|
||||
class="p-6 md:p-8 grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-16 list-none"
|
||||
>
|
||||
@for (pkg of filtered; track $index) {
|
||||
<marketplace-tile
|
||||
[pkg]="pkg"
|
||||
[style.--animation-order]="$index"
|
||||
class="block h-full"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<h1 class="text-xl pl-6">
|
||||
Loading
|
||||
<span class="loading-dots"></span>
|
||||
</h1>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<marketplace-sidebars />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
// TODO: Theme
|
||||
background: #18181b url('/assets/img/background.png') no-repeat top
|
||||
right;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [FilterPackagesPipe],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MarketplaceTileComponent,
|
||||
MarketplaceMenuComponent,
|
||||
MarketplaceNotificationComponent,
|
||||
MarketplaceControlsComponent,
|
||||
MarketplacePreviewComponent,
|
||||
MarketplaceSidebarsComponent,
|
||||
],
|
||||
})
|
||||
export class MarketplaceComponent {
|
||||
private readonly pipe = inject(FilterPackagesPipe)
|
||||
private readonly categoryService = inject(AbstractCategoryService)
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
|
||||
readonly details$ = this.marketplaceService.getSelectedHost$()
|
||||
readonly category$ = this.categoryService.getCategory$()
|
||||
readonly filtered$ = combineLatest([
|
||||
this.marketplaceService
|
||||
.getSelectedStore$()
|
||||
.pipe(map(({ packages }) => packages)),
|
||||
this.categoryService.getQuery$(),
|
||||
this.category$,
|
||||
]).pipe(map(args => this.pipe.transform(...args)))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
const MARKETPLACE_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
loadComponent: () =>
|
||||
import('./marketplace.component').then(m => m.MarketplaceComponent),
|
||||
},
|
||||
]
|
||||
|
||||
export default MARKETPLACE_ROUTES
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AboutModule,
|
||||
AbstractMarketplaceService,
|
||||
AdditionalModule,
|
||||
DependenciesModule,
|
||||
MarketplacePackageHeroComponent,
|
||||
MarketplacePkg,
|
||||
ReleaseNotesModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { map } from 'rxjs'
|
||||
import { Router } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-preview',
|
||||
template: `
|
||||
<div class="grid gap-8 p-7 justify-center">
|
||||
<ng-content select="[slot=close]" />
|
||||
<marketplace-package-hero [pkg]="pkg">
|
||||
<ng-content select="[slot=controls]" />
|
||||
</marketplace-package-hero>
|
||||
@if (url$ | async; as url) {
|
||||
<a
|
||||
[href]="url + '/marketplace/' + pkg.manifest.id"
|
||||
tuiButton
|
||||
appearance="tertiary-solid"
|
||||
iconRight="tuiIconExternalLink"
|
||||
target="_blank"
|
||||
>
|
||||
View more details
|
||||
</a>
|
||||
}
|
||||
<div class="grid grid-cols-1 gap-x-8">
|
||||
<marketplace-about [pkg]="pkg" />
|
||||
@if (!(pkg.manifest.dependencies | empty)) {
|
||||
<div
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10 mt-6"
|
||||
>
|
||||
<div class="lg:col-span-5 xl:col-span-4 bg-zinc-800 rounded-xl p-7">
|
||||
<h2 class="text-lg font-bold small-caps my-2 pb-3">
|
||||
Dependencies
|
||||
</h2>
|
||||
<div class="grid grid-row-auto gap-3">
|
||||
@for (
|
||||
dep of pkg.manifest.dependencies | keyvalue;
|
||||
track $index
|
||||
) {
|
||||
<marketplace-dependencies
|
||||
[dep]="dep"
|
||||
[pkg]="pkg"
|
||||
(click)="open(dep.key)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<release-notes [pkg]="pkg" />
|
||||
<marketplace-additional class="mt-6" [pkg]="pkg" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [':host { pointer-events: auto }'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MarketplacePackageHeroComponent,
|
||||
TuiButtonModule,
|
||||
DependenciesModule,
|
||||
ReleaseNotesModule,
|
||||
AdditionalModule,
|
||||
AboutModule,
|
||||
SharedPipesModule,
|
||||
],
|
||||
})
|
||||
export class MarketplacePreviewComponent {
|
||||
private readonly router = inject(Router)
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
readonly url$ = inject(AbstractMarketplaceService)
|
||||
.getSelectedHost$()
|
||||
.pipe(map(({ url }) => url))
|
||||
|
||||
open(id: string) {
|
||||
this.router.navigate([], { queryParams: { id } })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
toUrl,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
StoreIconComponentModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, filter, firstValueFrom, map, Subscription } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { MarketplaceRegistryComponent } from '../components/registry.component'
|
||||
import { getMarketplaceValueSpec, getPromptOptions } from '../utils/registry'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (stores$ | async; as stores) {
|
||||
<h3 class="g-title">Default Registries</h3>
|
||||
@for (registry of stores.standard; track $index) {
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="registry.selected"
|
||||
[registry]="registry"
|
||||
(click)="connect(registry.url)"
|
||||
></button>
|
||||
}
|
||||
<h3 class="g-title">Custom Registries</h3>
|
||||
<button tuiCell (click)="add()">
|
||||
<tui-icon icon="tuiIconPlus" [style.margin-inline.rem]="0.5" />
|
||||
<div tuiTitle>Add custom registry</div>
|
||||
</button>
|
||||
@for (registry of stores.alt; track $index) {
|
||||
<div tuiCell [registry]="registry">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconTrash2"
|
||||
(click)="delete(registry.url, registry.name)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconLogIn"
|
||||
(click)="connect(registry.url)"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
MarketplaceRegistryComponent,
|
||||
StoreIconComponentModule,
|
||||
],
|
||||
})
|
||||
export class MarketplaceRegistryModal {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly marketplace = inject(
|
||||
AbstractMarketplaceService,
|
||||
) as MarketplaceService
|
||||
private readonly hosts$ = inject(PatchDB<DataModel>).watch$(
|
||||
'ui',
|
||||
'marketplace',
|
||||
'known-hosts',
|
||||
)
|
||||
|
||||
readonly stores$ = combineLatest([
|
||||
this.marketplace.getKnownHosts$(),
|
||||
this.marketplace.getSelectedHost$(),
|
||||
]).pipe(
|
||||
map(([stores, selected]) =>
|
||||
stores.map(s => ({
|
||||
...s,
|
||||
selected: sameUrl(s.url, selected.url),
|
||||
})),
|
||||
),
|
||||
// 0 and 1 are prod and community, 2 and beyond are alts
|
||||
map(stores => ({ standard: stores.slice(0, 2), alt: stores.slice(2) })),
|
||||
)
|
||||
|
||||
add() {
|
||||
const { name, spec } = getMarketplaceValueSpec()
|
||||
|
||||
this.formDialog.open(FormPage, {
|
||||
label: name,
|
||||
data: {
|
||||
spec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save for Later',
|
||||
handler: async ({ url }: { url: string }) => this.save(url),
|
||||
},
|
||||
{
|
||||
text: 'Save and Connect',
|
||||
handler: async ({ url }: { url: string }) => this.save(url, true),
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
delete(url: string, name: string = '') {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, getPromptOptions(name))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
const hosts = await firstValueFrom(this.hosts$)
|
||||
const filtered: { [url: string]: UIStore } = Object.keys(hosts)
|
||||
.filter(key => !sameUrl(key, url))
|
||||
.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr]: hosts[curr],
|
||||
}),
|
||||
{},
|
||||
)
|
||||
|
||||
try {
|
||||
await this.api.setDbValue(['marketplace', 'known-hosts'], filtered)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async connect(
|
||||
url: string,
|
||||
loader: Subscription = new Subscription(),
|
||||
): Promise<void> {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Changing Registry...').subscribe())
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<string>(['marketplace', 'selected-url'], url)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async save(rawUrl: string, connect = false): Promise<boolean> {
|
||||
const loader = this.loader.open('Loading').subscribe()
|
||||
const url = new URL(rawUrl).toString()
|
||||
|
||||
try {
|
||||
await this.validateAndSave(url, loader)
|
||||
if (connect) await this.connect(url, loader)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAndSave(
|
||||
url: string,
|
||||
loader: Subscription,
|
||||
): Promise<void> {
|
||||
// Error on duplicates
|
||||
const hosts = await firstValueFrom(this.hosts$)
|
||||
const currentUrls = Object.keys(hosts).map(toUrl)
|
||||
if (currentUrls.includes(url)) throw new Error('Marketplace already added')
|
||||
|
||||
// Validate
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Validating marketplace...').subscribe())
|
||||
|
||||
const { name } = await firstValueFrom(this.marketplace.fetchInfo$(url))
|
||||
|
||||
// Save
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.api.setDbValue(['marketplace', 'known-hosts', url], { name })
|
||||
}
|
||||
}
|
||||
|
||||
export const MARKETPLACE_REGISTRY = new PolymorpheusComponent(
|
||||
MarketplaceRegistryModal,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, Observable } from 'rxjs'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Pipe({
|
||||
name: 'toLocal',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToLocalPipe implements PipeTransform {
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
|
||||
transform(id: string): Observable<PackageDataEntry> {
|
||||
return this.patch.watch$('package-data', id).pipe(filter(Boolean))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MarketplaceAlertsService {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly marketplace$ = inject(PatchDB<DataModel>).watch$(
|
||||
'ui',
|
||||
'marketplace',
|
||||
)
|
||||
|
||||
async alertMarketplace(url: string, originalUrl: string): Promise<boolean> {
|
||||
const marketplaces = await firstValueFrom(this.marketplace$)
|
||||
const name = marketplaces['known-hosts'][url]?.name || url
|
||||
const source = marketplaces['known-hosts'][originalUrl]?.name || originalUrl
|
||||
const message = source ? `installed from ${source}` : 'side loaded'
|
||||
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This service was originally ${message}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(defaultIfEmpty(false))
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
|
||||
async alertBreakages(breakages: string[]): Promise<boolean> {
|
||||
let content: string =
|
||||
'As a result of this update, the following services will no longer work properly and may crash:<ul>'
|
||||
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
|
||||
content = `${content}${bullets.join('')}</ul>`
|
||||
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(defaultIfEmpty(false))
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
|
||||
async alertInstall({ manifest }: MarketplacePkg): Promise<boolean> {
|
||||
const content = manifest.alerts.install
|
||||
|
||||
return (
|
||||
!!content &&
|
||||
new Promise(resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Alert',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Install',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(defaultIfEmpty(false))
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AbstractTuiPortalService } from '@taiga-ui/cdk'
|
||||
|
||||
@Injectable({ providedIn: `root` })
|
||||
export class MarketplaceSidebarService extends AbstractTuiPortalService {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiPromptData } from '@taiga-ui/kit'
|
||||
|
||||
export function getMarketplaceValueSpec(): ValueSpecObject {
|
||||
return {
|
||||
type: 'object',
|
||||
name: 'Add Custom Registry',
|
||||
description: null,
|
||||
warning: null,
|
||||
spec: {
|
||||
url: {
|
||||
type: 'text',
|
||||
name: 'URL',
|
||||
description: 'A fully-qualified URL of the custom registry',
|
||||
inputmode: 'url',
|
||||
required: true,
|
||||
masked: false,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [
|
||||
{
|
||||
regex: `https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-\.]+[a-zA-Z0-9]\.[^\s]{2,}`,
|
||||
description: 'Must be a valid URL',
|
||||
},
|
||||
],
|
||||
placeholder: 'e.g. https://example.org',
|
||||
default: null,
|
||||
warning: null,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
generate: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getPromptOptions(
|
||||
name: string,
|
||||
): Partial<TuiDialogOptions<TuiPromptData>> {
|
||||
return {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Are you sure you want to delete ${name}?`,
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { NavigationService } from '../../../services/navigation.service'
|
||||
selector: 'sideload-package',
|
||||
template: `
|
||||
<div class="grid gap-8 mb-16 p-4 lg:px-16 lg:pb-8 pt-14 justify-center">
|
||||
<ng-content></ng-content>
|
||||
<ng-content />
|
||||
<marketplace-package-hero
|
||||
*tuiLet="button$ | async as button"
|
||||
[pkg]="package"
|
||||
@@ -48,7 +48,7 @@ import { NavigationService } from '../../../services/navigation.service'
|
||||
</button>
|
||||
</div>
|
||||
</marketplace-package-hero>
|
||||
<marketplace-about [pkg]="package"></marketplace-about>
|
||||
<marketplace-about [pkg]="package" />
|
||||
<div
|
||||
*ngIf="!(package.manifest.dependencies | empty)"
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10"
|
||||
@@ -57,15 +57,12 @@ import { NavigationService } from '../../../services/navigation.service'
|
||||
<h2 class="text-lg font-bold small-caps my-2 pb-3">Dependencies</h2>
|
||||
<div class="grid grid-row-auto gap-3">
|
||||
<div *ngFor="let dep of package.manifest.dependencies | keyvalue">
|
||||
<marketplace-dependencies
|
||||
[dep]="dep"
|
||||
[pkg]="package"
|
||||
></marketplace-dependencies>
|
||||
<marketplace-dependencies [dep]="dep" [pkg]="package" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<marketplace-additional [pkg]="package"></marketplace-additional>
|
||||
<marketplace-additional [pkg]="package" />
|
||||
</div>
|
||||
`,
|
||||
standalone: true,
|
||||
|
||||
@@ -11,6 +11,12 @@ const ROUTES: Routes = [
|
||||
import('./backups/backups.component').then(m => m.BackupsComponent),
|
||||
data: toNavigationItem('/portal/system/backups'),
|
||||
},
|
||||
{
|
||||
title: systemTabResolver,
|
||||
path: 'marketplace',
|
||||
loadChildren: () => import('./marketplace/marketplace.routes'),
|
||||
data: toNavigationItem('/portal/system/marketplace'),
|
||||
},
|
||||
{
|
||||
title: systemTabResolver,
|
||||
path: 'settings',
|
||||
|
||||
@@ -16,9 +16,12 @@ import {
|
||||
TuiDialogService,
|
||||
TuiLinkModule,
|
||||
TuiLoaderModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiAvatarModule, TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiAvatarModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TUI_PROMPT,
|
||||
TuiAccordionModule,
|
||||
@@ -35,27 +38,25 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
|
||||
template: `
|
||||
<tui-accordion-item borders="top-bottom">
|
||||
<div class="g-action">
|
||||
<tui-avatar size="s" [src]="marketplacePkg | mimeType | trustUrl" />
|
||||
<tui-avatar size="s" [src]="marketplacePkg | mimeType" />
|
||||
<div [style.flex]="1" [style.overflow]="'hidden'">
|
||||
<strong>{{ marketplacePkg.manifest.title }}</strong>
|
||||
<div>
|
||||
<!-- @TODO left side should be local['old-manifest'] (or whatever), not manifest. -->
|
||||
{{ localPkg.manifest.version || '' | displayEmver }}
|
||||
<tui-svg src="tuiIconArrowRight"></tui-svg>
|
||||
<tui-icon icon="tuiIconArrowRight" [style.font-size.rem]="1" />
|
||||
<span [style.color]="'var(--tui-positive)'">
|
||||
{{ marketplacePkg.manifest.version | displayEmver }}
|
||||
</span>
|
||||
</div>
|
||||
<div [style.color]="'var(--tui-negative)'">
|
||||
{{ errors }}
|
||||
</div>
|
||||
<div [style.color]="'var(--tui-negative)'">{{ errors }}</div>
|
||||
</div>
|
||||
<tui-progress-circle
|
||||
*ngIf="localPkg.state === 'updating'; else button"
|
||||
style="color: var(--tui-positive)"
|
||||
[max]="100"
|
||||
[value]="localPkg['install-progress'] | installProgress"
|
||||
></tui-progress-circle>
|
||||
/>
|
||||
<ng-template #button>
|
||||
<button
|
||||
*ngIf="ready; else queued"
|
||||
@@ -68,7 +69,7 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template #queued>
|
||||
<tui-loader [style.width.rem]="2" [inheritColor]="true"></tui-loader>
|
||||
<tui-loader [style.width.rem]="2" [inheritColor]="true" />
|
||||
</ng-template>
|
||||
</div>
|
||||
<ng-template tuiAccordionItemContent>
|
||||
@@ -116,7 +117,7 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
|
||||
TuiProgressModule,
|
||||
TuiAccordionModule,
|
||||
TuiAvatarModule,
|
||||
TuiSvgModule,
|
||||
TuiIconModule,
|
||||
TuiButtonModule,
|
||||
TuiLinkModule,
|
||||
TuiLoaderModule,
|
||||
|
||||
@@ -44,7 +44,7 @@ import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-show-controls',
|
||||
@@ -67,7 +67,7 @@ import { TuiButtonModule } from '@taiga-ui/core'
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="warning-solid"
|
||||
*ngIf="(localVersion | compareEmver : pkg.manifest.version) === -1"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === -1"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Update
|
||||
@@ -77,7 +77,7 @@ import { TuiButtonModule } from '@taiga-ui/core'
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="secondary-solid"
|
||||
*ngIf="(localVersion | compareEmver : pkg.manifest.version) === 1"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 1"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Downgrade
|
||||
@@ -88,7 +88,7 @@ import { TuiButtonModule } from '@taiga-ui/core'
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="tertiary-solid"
|
||||
*ngIf="(localVersion | compareEmver : pkg.manifest.version) === 0"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 0"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Reinstall
|
||||
@@ -101,7 +101,7 @@ import { TuiButtonModule } from '@taiga-ui/core'
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="primary-solid"
|
||||
appearance="primary"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Install
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@start9labs/marketplace'
|
||||
import { MarketplaceShowPreviewComponent } from './marketplace-show-preview.component'
|
||||
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { BackupTargetType, Metrics, RR } from './api.types'
|
||||
import { Mock } from './api.fixures'
|
||||
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
|
||||
// import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
|
||||
import {
|
||||
EMPTY,
|
||||
iif,
|
||||
@@ -82,7 +82,7 @@ export class MockApiService extends ApiService {
|
||||
|
||||
async getStatic(url: string): Promise<string> {
|
||||
await pauseFor(2000)
|
||||
return markdown
|
||||
return '' // markdown
|
||||
}
|
||||
|
||||
async uploadPackage(guid: string, body: Blob): Promise<void> {
|
||||
@@ -458,7 +458,7 @@ export class MockApiService extends ApiService {
|
||||
} else if (path.startsWith('/package/v0/release-notes')) {
|
||||
return Mock.ReleaseNotes
|
||||
} else if (path.includes('instructions') || path.includes('license')) {
|
||||
return markdown
|
||||
return '' // markdown
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -476,6 +476,10 @@ button.g-action {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
ng-component {
|
||||
display: block;
|
||||
}
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -486,4 +490,4 @@ svg:not(:root) {
|
||||
|
||||
.externalLink {
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user