fix: fix build after minor merged into major

This commit is contained in:
waterplea
2024-08-15 12:40:49 +04:00
parent b43ad93c54
commit a730543c76
189 changed files with 714 additions and 3652 deletions

1
web/package-lock.json generated
View File

@@ -31,7 +31,6 @@
"@taiga-ui/cdk": "4.0.0-rc.7",
"@taiga-ui/core": "4.0.0-rc.7",
"@taiga-ui/event-plugins": "^4.0.1",
"@taiga-ui/experimental": "4.0.0-rc.7",
"@taiga-ui/icons": "4.0.0-rc.7",
"@taiga-ui/kit": "4.0.0-rc.7",
"@taiga-ui/layout": "4.0.0-rc.7",

View File

@@ -51,7 +51,6 @@
"@taiga-ui/cdk": "4.0.0-rc.7",
"@taiga-ui/core": "4.0.0-rc.7",
"@taiga-ui/event-plugins": "^4.0.1",
"@taiga-ui/experimental": "4.0.0-rc.7",
"@taiga-ui/icons": "4.0.0-rc.7",
"@taiga-ui/kit": "4.0.0-rc.7",
"@taiga-ui/layout": "4.0.0-rc.7",

View File

@@ -6,6 +6,8 @@
"@angular/core": ">=13.2.0",
"@start9labs/shared": ">=0.3.2",
"@taiga-ui/cdk": "4.0.0-rc.6",
"@taiga-ui/core": "4.0.0-rc.6",
"@taiga-ui/layout": "4.0.0-rc.6",
"@tinkoff/ng-dompurify": ">=4.0.0",
"fuse.js": "^6.4.6"
},

View File

@@ -0,0 +1,90 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { Exver, MarkdownPipeModule } from '@start9labs/shared'
import { TuiButton, TuiLoader } from '@taiga-ui/core'
import { TuiCardLarge } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { map } from 'rxjs'
import { AbstractMarketplaceService } from '../services/marketplace.service'
import { MarketplacePkg } from '../types'
@Component({
standalone: true,
template: `
@if (notes$ | async; as notes) {
@for (note of notes | keyvalue: asIsOrder; track $index) {
<button tuiButton (click)="setSelected(note.key)">
{{ note.key }}
</button>
<div
tuiCardLarge
#element
[id]="note.key"
[style.max-height.px]="getDocSize(note.key, element)"
[innerHTML]="note.value | markdown"
></div>
}
} @else {
<tui-loader textContent="Loading Release Notes" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiButton,
TuiLoader,
TuiCardLarge,
MarkdownPipeModule,
],
})
export class ReleaseNotesComponent {
@Input() pkg!: MarketplacePkg
private selected: string | null = null
private readonly exver = inject(Exver)
readonly notes$ = inject(AbstractMarketplaceService)
.getSelectedStore$()
.pipe(
map(s => {
return Object.entries(this.pkg.otherVersions)
.filter(
([v, _]) =>
this.exver.getFlavor(v) === this.pkg.flavor &&
this.exver.compareExver(this.pkg.version, v) === 1,
)
.reduce(
(obj, [version, info]) => ({
...obj,
[version]: info.releaseNotes,
}),
{
[`${this.pkg.version} (current)`]: this.pkg.releaseNotes,
},
)
}),
)
isSelected(key: string): boolean {
return this.selected === key
}
setSelected(selected: string) {
this.selected = this.isSelected(selected) ? null : selected
}
getDocSize(key: string, { scrollHeight }: HTMLElement) {
return this.isSelected(key) ? scrollHeight : 0
}
asIsOrder(a: any, b: any) {
return 0
}
}
export const RELEASE_NOTES = new PolymorpheusComponent(ReleaseNotesComponent)

View File

@@ -1,51 +0,0 @@
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<h2 class="additional-detail-title">What's new</h2>
<div class="box-container-details">
<div class="box-container-details-version">
<h3>Version {{ pkg.manifest.version }}</h3>
<p
safeLinks
class="box-container-details-notes"
[innerHTML]="pkg.manifest.releaseNotes | markdown | dompurify"
></p>
</div>
<button
*ngIf="pkg.versions.length > 2"
tuiButton
type="button"
appearance="secondary"
size="m"
(click)="showReleaseNotes(template)"
>
Previous releases
</button>
</div>
</div>
</div>
<ng-template #template let-observer>
<ng-container *ngIf="notes$ | async as notes; else loading">
<tui-accordion
class="max-width-lg"
style="max-width: 32rem"
[closeOthers]="false"
*ngFor="
let note of notes
| filterVersions: pkg.manifest.version
| keyvalue: asIsOrder
"
>
<tui-accordion-item style="margin: 0.25rem 0">
{{ note.key | displayEmver }}
<ng-template tuiAccordionItemContent>
<p safeLinks [innerHTML]="note.value | markdown | dompurify"></p>
</ng-template>
</tui-accordion-item>
</tui-accordion>
</ng-container>
<ng-template #loading>
<tui-loader textContent="Loading Release Notes" />
</ng-template>
</ng-template>

View File

@@ -1,44 +0,0 @@
.box-container {
background-color: rgb(39 39 42);
border-radius: 0.75rem;
padding: 1.75rem;
display: grid;
grid-auto-flow: row;
align-items: center;
&-details {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
&-version {
line-height: 1.5rem;
font-size: 1rem;
h3 {
font-size: 1.2rem;
margin-bottom: 1.25rem;
pointer-events: none;
}
}
&-notes {
flex-wrap: wrap;
margin-top: 0.25rem;
pointer-events: none;
}
button {
margin-top: 0.75rem;
place-self: end;
@media (min-width: 768px) {
place-self: start;
}
@media (min-width: 1024px) {
place-self: end;
}
}
}
}

View File

@@ -1,70 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
} from '@angular/core'
import { AbstractMarketplaceService } from '../../services/marketplace.service'
import { MarketplacePkg } from '../../types'
import { map } from 'rxjs'
import { Exver } from '@start9labs/shared'
// @TODO Alex use Taiga modal
import { ModalController } from '@ionic/angular'
@Component({
selector: 'release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseNotesComponent {
@Input() pkg!: MarketplacePkg
private selected: string | null = null
readonly notes$ = this.marketplaceService.getSelectedStore$().pipe(
map(s => {
return Object.entries(this.pkg.otherVersions)
.filter(
([v, _]) =>
this.exver.getFlavor(v) === this.pkg.flavor &&
this.exver.compareExver(this.pkg.version, v) === 1,
)
.reduce(
(obj, [version, info]) => ({
...obj,
[version]: info.releaseNotes,
}),
{
[`${this.pkg.version} (current)`]: this.pkg.releaseNotes,
},
)
}),
)
constructor(
private readonly marketplaceService: AbstractMarketplaceService,
private readonly exver: Exver,
private readonly modalCtrl: ModalController,
) {}
async dismiss() {
return this.modalCtrl.dismiss()
}
isSelected(key: string): boolean {
return this.selected === key
}
setSelected(selected: string) {
this.selected = this.isSelected(selected) ? null : selected
}
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
return this.isSelected(key) ? nativeElement.scrollHeight : 0
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -1,32 +0,0 @@
import { TuiAccordion } from '@taiga-ui/kit'
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import {
ExverPipesModule,
MarkdownPipeModule,
SafeLinksDirective,
} from '@start9labs/shared'
import { TuiLoader, TuiButton } from '@taiga-ui/core'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import {
FilterVersionsPipe,
ReleaseNotesComponent,
} from './release-notes.component'
import { ReleaseNotesComponent } from './release-notes.component'
@NgModule({
imports: [
CommonModule,
ExverPipesModule,
MarkdownPipeModule,
NgDompurifyModule,
SafeLinksDirective,
TuiButton,
...TuiAccordion,
TuiLoader,
FilterVersionsPipe,
],
declarations: [ReleaseNotesComponent],
exports: [ReleaseNotesComponent],
})
export class ReleaseNotesComponentModule {}

View File

@@ -1,14 +1,14 @@
<button
*ngFor="let cat of categories || ['', '', '', '', '', '']"
(click)="switchCategory(cat)"
[class.category_selected]="cat === category"
*ngFor="let cat of categories || fallback | keyvalue"
(click)="switchCategory(cat.key)"
[class.category_selected]="cat.key === category"
>
<div
class="category-wrapper"
[class.tui-skeleton]="!categories"
[class.tui-skeleton_rounded]="!categories"
>
<tui-icon tuiAppearance="icon" icon="{{ determineIcon(cat) }}"></tui-icon>
<tui-icon tuiAppearance="icon" [icon]="determineIcon(cat.key)" />
</div>
<span
class="category-title"
@@ -16,9 +16,9 @@
[class.tui-skeleton_rounded]="!categories"
>
{{
cat === 'ai'
? (cat | uppercase)
: (cat | titlecase) || 'Loading category...'
cat.key === 'ai'
? (cat.key | uppercase)
: (cat.value.name | titlecase) || 'Loading category...'
}}
</span>
</button>

View File

@@ -15,7 +15,7 @@ import { T } from '@start9labs/start-sdk'
})
export class CategoriesComponent {
@Input()
categories!: Map<string, T.Category>
categories?: Record<string, T.Category>
@Input()
category = ''
@@ -23,6 +23,14 @@ export class CategoriesComponent {
@Output()
readonly categoryChange = new EventEmitter<string>()
readonly fallback: Record<string, T.Category> = {
a: { name: 'a', description: { short: 'a', long: 'a' } },
b: { name: 'a', description: { short: 'a', long: 'a' } },
c: { name: 'a', description: { short: 'a', long: 'a' } },
d: { name: 'a', description: { short: 'a', long: 'a' } },
e: { name: 'a', description: { short: 'a', long: 'a' } },
}
switchCategory(category: string): void {
this.category = category
this.categoryChange.emit(category)

View File

@@ -25,7 +25,7 @@ export class ItemComponent {
const iconUrl = new URL(this.pkg.icon)
return iconUrl.href
} catch (e) {
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
}
}
}

View File

@@ -1,8 +1,23 @@
<div class="background-border box-shadow-lg shadow-color-light">
<div class="box-container">
<h2 class="additional-detail-title">New in {{ pkg.version }}</h2>
<p [innerHTML]="pkg.releaseNotes | markdown"></p>
<button tuiButton iconEnd="@tui.chevron-right" (click)="onPast()">
Past Release Notes
</button>
</div>
<div class="box-container">
<h2 class="additional-detail-title">About</h2>
<p>
{{ pkg.manifest.description.long }}
</p>
<p>{{ pkg.description.long }}</p>
<a
*ngIf="pkg.marketingSite as url"
tuiButton
iconEnd="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="url"
>
View website
</a>
</div>
</div>

View File

@@ -1,7 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiDialogService } from '@taiga-ui/core'
import { RELEASE_NOTES } from '../../../modals/release-notes.component'
import { MarketplacePkg } from '../../../types'
import { ModalController } from '@ionic/angular'
import { ReleaseNotesComponent } from '../../../modals/release-notes/release-notes.component'
@Component({
selector: 'marketplace-about',
@@ -10,17 +15,14 @@ import { ReleaseNotesComponent } from '../../../modals/release-notes/release-not
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AboutComponent {
private readonly dialogs = inject(TuiDialogService)
@Input({ required: true })
pkg!: MarketplacePkg
constructor(private readonly modalCtrl: ModalController) {}
async presentModalNotes() {
const modal = await this.modalCtrl.create({
componentProps: { pkg: this.pkg },
component: ReleaseNotesComponent,
})
await modal.present()
async onPast() {
this.dialogs
.open(RELEASE_NOTES, { label: 'Past Release Notes' })
.subscribe()
}
}

View File

@@ -1,10 +1,11 @@
import { TuiButton } from '@taiga-ui/core'
import { TuiTagModule } from '@taiga-ui/legacy'
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { AboutComponent } from './about.component'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { SafeLinksDirective } from '@start9labs/shared'
import { MarkdownPipeModule, SafeLinksDirective } from '@start9labs/shared'
@NgModule({
imports: [
@@ -13,6 +14,8 @@ import { SafeLinksDirective } from '@start9labs/shared'
TuiTagModule,
NgDompurifyModule,
SafeLinksDirective,
MarkdownPipeModule,
TuiButton,
],
declarations: [AboutComponent],
exports: [AboutComponent],

View File

@@ -4,11 +4,11 @@
<div class="detail-container">
<!-- release date -->
<marketplace-additional-item
*ngIf="pkg.publishedAt as published"
*ngIf="pkg.s9pk.publishedAt as published"
[data]="(published | date: 'medium')!"
label="Released"
icon=""
></marketplace-additional-item>
/>
<!-- git hash -->
<marketplace-additional-item
*ngIf="pkg.gitHash as gitHash; else noHash"
@@ -17,7 +17,7 @@
label="Git Hash"
icon="@tui.copy"
class="item-copy"
></marketplace-additional-item>
/>
<ng-template #noHash>
<div class="item-padding">
<label tuiTitle>
@@ -33,7 +33,7 @@
label="License"
icon="@tui.chevron-right"
class="item-pointer"
></marketplace-additional-item>
/>
<!-- instructions -->
<marketplace-additional-item
(click)="presentModalMd('Instructions')"
@@ -41,7 +41,7 @@
label="Instructions"
icon="@tui.chevron-right"
class="item-pointer"
></marketplace-additional-item>
/>
<!-- versions -->
<ng-content />
<!-- links -->
@@ -51,28 +51,28 @@
label="Marketing Site"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
/>
<marketplace-additional-link
*ngIf="pkg.upstreamRepo"
[url]="pkg.upstreamRepo"
label="Source Repository"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
/>
<marketplace-additional-link
*ngIf="pkg.wrapperRepo"
[url]="pkg.wrapperRepo"
label="Wrapper Repository"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
/>
<marketplace-additional-link
*ngIf="pkg.supportSite"
[url]="pkg.supportSite"
label="Support Site"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
/>
</div>
</div>
</div>

View File

@@ -7,7 +7,7 @@ import {
import { ActivatedRoute } from '@angular/router'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { CopyService, Exver, MarkdownComponent } from '@start9labs/shared'
import { CopyService, MarkdownComponent } from '@start9labs/shared'
import { MarketplacePkg } from '../../../types'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
@@ -38,9 +38,8 @@ export class AdditionalComponent {
size: 'l',
data: {
content: this.marketplaceService.fetchStatic$(
this.pkg.id,
label.toLowerCase(),
this.url,
this.pkg,
label === 'License' ? 'LICENSE.md' : 'instructions.md',
),
},
})

View File

@@ -1,14 +1,13 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
inject,
} from '@angular/core'
import { CommonModule } from '@angular/common'
import { MarketplaceDepItemComponent } from './dependency-item.component'
import { MarketplacePkg } from '../../../types'
import { MarketplaceDepItemComponent } from './dependency-item.component'
@Component({
selector: 'marketplace-dependencies',
@@ -17,7 +16,7 @@ import { MarketplacePkg } from '../../../types'
<div class="dependencies-container">
<h2 class="additional-detail-title">Dependencies</h2>
<div class="dependencies-list">
@for (dep of pkg.manifest.dependencies | keyvalue; track $index) {
@for (dep of pkg.dependencyMetadata | keyvalue; track $index) {
<marketplace-dep-item
[dep]="dep"
[pkg]="pkg"

View File

@@ -2,15 +2,16 @@ import { CommonModule, KeyValue } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
Input,
inject,
Input,
} from '@angular/core'
import { RouterModule } from '@angular/router'
import { EmverPipesModule } from '@start9labs/shared'
import { ExverPipesModule } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
import { Dependency, MarketplacePkg, StoreIdentity } from '../../../types'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
import { MarketplacePkg, StoreIdentity } from '../../../types'
@Component({
selector: 'marketplace-dep-item',
@@ -104,7 +105,7 @@ import { AbstractMarketplaceService } from '../../../services/marketplace.servic
CommonModule,
RouterModule,
TuiAvatar,
EmverPipesModule,
ExverPipesModule,
TuiLineClamp,
TuiLet,
],
@@ -114,7 +115,7 @@ export class MarketplaceDepItemComponent {
pkg!: MarketplacePkg
@Input({ required: true })
dep!: KeyValue<string, Dependency>
dep!: KeyValue<string, T.DependencyMetadata>
private readonly marketplaceService = inject(AbstractMarketplaceService)
readonly marketplace$ = this.marketplaceService.getSelectedHost$()

View File

@@ -1,21 +0,0 @@
<ion-item-divider>Alternative Implementations</ion-item-divider>
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of pkgs" responsiveCol sizeSm="12" sizeMd="6">
<ion-item
[routerLink]="['/marketplace', pkg.id]"
[queryParams]="{ flavor: pkg.flavor }"
>
<ion-thumbnail slot="start">
<img alt="" style="border-radius: 100%" [src]="pkg.icon | trustUrl" />
</ion-thumbnail>
<ion-label>
<h2>
{{ pkg.title }}
</h2>
<p>{{ pkg.version }}</p>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -1,10 +1,31 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { MarketplacePkg } from '../../../types'
@Component({
standalone: true,
selector: 'marketplace-flavors',
templateUrl: 'flavors.component.html',
template: `
<h2>Alternative Implementations</h2>
@for (pkg of pkgs; track $index) {
<a
tuiCell
[routerLink]="['/marketplace', pkg.id]"
[queryParams]="{ flavor: pkg.flavor }"
>
<img alt="" style="border-radius: 100%" [src]="pkg.icon | trustUrl" />
<span tuiTitle>
{{ pkg.title }}
<span tuiSubtitle>{{ pkg.version }}</span>
</span>
</a>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, TuiCell, TuiTitle, SharedPipesModule],
})
export class FlavorsComponent {
@Input()

View File

@@ -1,19 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ResponsiveColModule, SharedPipesModule } from '@start9labs/shared'
import { FlavorsComponent } from './flavors.component'
@NgModule({
imports: [
CommonModule,
RouterModule,
IonicModule,
SharedPipesModule,
ResponsiveColModule,
],
declarations: [FlavorsComponent],
exports: [FlavorsComponent],
})
export class FlavorsModule {}

View File

@@ -18,26 +18,26 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
<!-- icon -->
<img
[src]="determineIcon(marketplace) | trustUrl"
alt="{{ pkg.manifest.title }} Icon"
alt="{{ pkg.title }} Icon"
/>
<!-- color background -->
<div class="color-background">
<img
[src]="determineIcon(marketplace) | trustUrl"
alt="{{ pkg.manifest.title }} background image"
alt="{{ pkg.title }} background image"
/>
</div>
<!-- background darkening overlay -->
<div class="dark-overlay"></div>
<div class="inner-container-title">
<h2 ticker>
{{ pkg.manifest.title }}
{{ pkg.title }}
</h2>
<h3>
{{ pkg.manifest.version }}
{{ pkg.version }}
</h3>
<p>
{{ pkg.manifest.description.short }}
{{ pkg.description.short }}
</p>
</div>
<!-- control buttons -->
@@ -175,7 +175,7 @@ export class MarketplacePackageHeroComponent {
const iconUrl = new URL(this.pkg.icon)
return iconUrl.href
} catch (e) {
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
}
}
}

View File

@@ -14,11 +14,16 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
@Component({
selector: 'marketplace-package-screenshots',
template: `
<div *ngIf="pkg.screenshots" tuiCarouselButtons class="outer-container">
<!--@TODO Matt or Lucy?-->
<div
*ngIf="$any(pkg).screenshots as screenshots"
tuiCarouselButtons
class="outer-container"
>
<button
tuiIconButton
appearance="flat"
icon="@tui.chevron-left"
iconStart="@tui.chevron-left"
title="Previous"
type="button"
(click)="carousel.prev()"
@@ -29,7 +34,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
[(index)]="index"
class="carousel"
>
<ng-container *ngFor="let item of pkg.screenshots; let i = index">
<ng-container *ngFor="let item of screenshots; let i = index">
<div
*tuiItem
draggable="false"
@@ -57,7 +62,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
tuiIconButton
appearance="flat"
type="button"
icon="@tui.chevron-right"
iconStart="@tui.chevron-right"
title="Next"
(click)="carousel.next()"
></button>

View File

@@ -19,7 +19,6 @@ export * from './pages/show/dependencies/dependency-item.component'
export * from './pages/show/screenshots/screenshots.component'
export * from './pages/show/hero/hero.component'
export * from './pages/show/flavors/flavors.component'
export * from './pages/show/flavors/flavors.module'
export * from './pipes/filter-packages.pipe'

View File

@@ -1,11 +1,5 @@
import { Observable } from 'rxjs'
import {
Marketplace,
MarketplacePkg,
StoreData,
StoreIdentity,
StoreIdentityWithData,
} from '../types'
import { Marketplace, MarketplacePkg, StoreData, StoreIdentity } from '../types'
export abstract class AbstractMarketplaceService {
abstract getKnownHosts$(): Observable<StoreIdentity[]>
@@ -16,7 +10,9 @@ export abstract class AbstractMarketplaceService {
abstract getSelectedStore$(): Observable<StoreData>
abstract getSelectedStoreWithCategories$(): Observable<StoreIdentityWithData>
abstract getSelectedStoreWithCategories$(): Observable<
StoreIdentity & StoreData
>
abstract getPackage$(
id: string,

View File

@@ -0,0 +1,27 @@
import { Directive, ElementRef, inject, input, Output } from '@angular/core'
import { StartOSDiskInfo } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter, fromEvent, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
@Directive({
standalone: true,
selector: 'button[server][password]',
})
export class PasswordDirective {
private readonly dialogs = inject(TuiDialogService)
readonly server = input.required<StartOSDiskInfo>()
@Output()
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
switchMap(() =>
this.dialogs.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { passwordHash: this.server().passwordHash },
}),
),
filter(Boolean),
)
}

View File

@@ -1,10 +1,11 @@
import { Component, inject } from '@angular/core'
import { ServerComponent } from '@start9labs/shared'
import { TuiDialogContext } from '@taiga-ui/core'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@taiga-ui/polymorpheus'
import { ServerComponent } from 'src/app/components/server.component'
import { PasswordDirective } from 'src/app/components/password.directive'
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
interface Data {
@@ -23,7 +24,7 @@ export interface ServersResponse {
<button [server]="server" (password)="select($event, server.id)"></button>
}
`,
imports: [ServerComponent],
imports: [ServerComponent, PasswordDirective],
})
export class ServersComponent {
readonly context =

View File

@@ -1,7 +1,11 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Router } from '@angular/router'
import { ErrorService, InitializingComponent } from '@start9labs/shared'
import {
ErrorService,
formatProgress,
InitializingComponent,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
@@ -40,9 +44,8 @@ export default class LoadingPage {
startWith(progress),
catchError((_, watch$) =>
interval(2000).pipe(
switchMap(() =>
from(this.api.getStatus()).pipe(catchError(() => EMPTY)),
),
switchMap(() => from(this.api.getStatus())),
catchError(() => EMPTY),
take(1),
switchMap(() => watch$),
),
@@ -54,13 +57,7 @@ export default class LoadingPage {
}),
),
),
map(({ phases, overall }) => ({
total: getDecimal(overall),
message: phases
.filter(p => p.progress !== true && p.progress !== null)
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
.join(','),
})),
map(formatProgress),
catchError(e => {
this.errorService.handleError(e)
return EMPTY
@@ -87,19 +84,3 @@ export default class LoadingPage {
}
}
}
function getDecimal(progress: T.Progress): number {
if (progress === true) {
return 1
} else if (!progress || !progress.total) {
return 0
} else {
return progress.total && progress.done / progress.total
}
}
function getPhaseBytes(progress: T.Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`
}

View File

@@ -1,7 +1,7 @@
import { DatePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ErrorService, ServerComponent } from '@start9labs/shared'
import {
TuiButton,
TuiDialogService,
@@ -11,7 +11,7 @@ import {
} from '@taiga-ui/core'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
import { ServerComponent } from 'src/app/components/server.component'
import { PasswordDirective } from 'src/app/components/password.directive'
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@@ -66,6 +66,7 @@ import { StateService } from 'src/app/services/state.service'
TuiTitle,
DatePipe,
ServerComponent,
PasswordDirective,
],
})
export default class RecoverPage {

View File

@@ -7,10 +7,9 @@
"@angular/router": "^17.0.6",
"@ng-web-apis/mutation-observer": ">=4.0.0",
"@ng-web-apis/resize-observer": ">=4.0.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/cdk": "4.0.0-rc.6",
"@taiga-ui/core": "4.0.0-rc.6",
"@taiga-ui/experimental": "4.0.0-rc.6",
"@taiga-ui/cdk": "4.0.0-rc.7",
"@taiga-ui/core": "4.0.0-rc.7",
"@taiga-ui/layout": "4.0.0-rc.7",
"@tinkoff/ng-dompurify": ">=4.0.0",
"ansi-to-html": "^0.7.2"
},

View File

@@ -1,10 +1,8 @@
import { DatePipe } from '@angular/common'
import { Component, ElementRef, inject, input, Output } from '@angular/core'
import { StartOSDiskInfo } from '@start9labs/shared'
import { Component, inject, input } from '@angular/core'
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { filter, fromEvent, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
import { StartOSDiskInfo } from '../types/api'
@Component({
standalone: true,
@@ -31,16 +29,4 @@ export class ServerComponent {
private readonly dialogs = inject(TuiDialogService)
readonly server = input.required<StartOSDiskInfo>()
@Output()
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
switchMap(() =>
this.dialogs.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { passwordHash: this.server().passwordHash },
}),
),
filter(Boolean),
)
}

View File

@@ -15,6 +15,7 @@ export * from './components/markdown/markdown.component.module'
export * from './components/ticker/ticker.component'
export * from './components/ticker/ticker.module'
export * from './components/drive.component'
export * from './components/server.component'
export * from './directives/drag-scroller.directive'
export * from './directives/safe-links.directive'
@@ -50,6 +51,7 @@ export * from './tokens/theme'
export * from './util/base-64'
export * from './util/convert-ansi'
export * from './util/copy-to-clipboard'
export * from './util/format-progress'
export * from './util/get-new-entries'
export * from './util/get-pkg-id'
export * from './util/invert'

View File

@@ -0,0 +1,33 @@
// @TODO Matt this is T.FullProgress but shared does not depend on sdk
type Progress = null | boolean | { done: number; total: number | null }
type NamedProgress = { name: string; progress: Progress }
type FullProgress = { overall: Progress; phases: Array<NamedProgress> }
export function formatProgress({ phases, overall }: FullProgress): {
total: number
message: string
} {
return {
total: getDecimal(overall),
message: phases
.filter(p => p.progress !== true && p.progress !== null)
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
.join(', '),
}
}
function getDecimal(progress: Progress): number {
if (progress === true) {
return 1
} else if (!progress || !progress.total) {
return 0
} else {
return progress.total && progress.done / progress.total
}
}
function getPhaseBytes(progress: Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`
}

View File

@@ -17,7 +17,7 @@ import { PatchMonitorService } from './services/patch-monitor.service'
})
export class AppComponent implements OnInit {
private readonly title = inject(Title)
private readonly patch = inject(PatchDB<DataModel>)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly auth = inject(AuthService)
readonly theme$ = inject(THEME)
@@ -29,7 +29,7 @@ export class AppComponent implements OnInit {
.subscribe()
readonly offline$ = combineLatest([
inject(ConnectionService).connected$,
inject(ConnectionService),
this.auth.isVerified$,
this.patch
.watch$('serverInfo', 'statusInfo')

View File

@@ -9,6 +9,7 @@ import {
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
import {
TUI_DATE_FORMAT,
TUI_DIALOGS_CLOSE,
tuiButtonOptionsProvider,
tuiDropdownOptionsProvider,
tuiNumberFormatProvider,
@@ -19,7 +20,13 @@ import {
TUI_DATE_VALUE_TRANSFORMER,
} from '@taiga-ui/kit'
import { tuiTextfieldOptionsProvider } from '@taiga-ui/legacy'
import { PATCH_DB_PROVIDERS } from 'src/app/services/patch-db/patch-db.providers'
import { PatchDB } from 'patch-db-client'
import { filter, pairwise } from 'rxjs'
import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
import { StateService } from 'src/app/services/state.service'
import { ApiService } from './services/api/embassy-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
@@ -29,8 +36,8 @@ import { ClientStorageService } from './services/client-storage.service'
import { DateTransformerService } from './services/date-transformer.service'
import { DatetimeTransformerService } from './services/datetime-transformer.service'
import { MarketplaceService } from './services/marketplace.service'
import { ThemeSwitcherService } from './services/theme-switcher.service'
import { StorageService } from './services/storage.service'
import { ThemeSwitcherService } from './services/theme-switcher.service'
const {
useMocks,
@@ -38,7 +45,6 @@ const {
} = require('../../../../config.json') as WorkspaceConfig
export const APP_PROVIDERS: Provider[] = [
PATCH_DB_PROVIDERS,
NG_EVENT_PLUGINS,
FilterPackagesPipe,
UntypedFormBuilder,

View File

@@ -1,167 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnInit,
} from '@angular/core'
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { CT } from '@start9labs/start-sdk'
import {
tuiMarkControlAsTouchedAndValidate,
TuiValueChangesModule,
} from '@taiga-ui/cdk'
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiDialogFormService } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { FormModule } from 'src/app/components/form/form.module'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { FormService } from 'src/app/services/form.service'
export interface ActionButton<T> {
text: string
handler?: (value: T) => Promise<boolean | void> | void
link?: string
}
export interface FormContext<T> {
spec: CT.InputSpec
buttons: ActionButton<T>[]
value?: T
patch?: Operation[]
}
@Component({
standalone: true,
selector: 'app-form',
template: `
<form
[formGroup]="form"
(submit.capture.prevent)="(0)"
(reset.capture.prevent.stop)="onReset()"
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec"></form-group>
<footer tuiMode="onDark">
<ng-content></ng-content>
<ng-container *ngFor="let button of buttons; let last = last">
<button
*ngIf="button.handler; else link"
tuiButton
[appearance]="last ? 'primary' : 'flat'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
<ng-template #link>
<a
tuiButton
appearance="flat"
[routerLink]="button.link"
(click)="close()"
>
{{ button.text }}
</a>
</ng-template>
</ng-container>
</footer>
</form>
`,
styles: [
`
footer {
position: sticky;
bottom: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
padding: 1rem 0;
margin: 1rem 0 -1rem;
gap: 1rem;
background: var(--tui-elevation-01);
border-top: 1px solid var(--tui-base-02);
}
`,
],
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
TuiValueChangesModule,
TuiButtonModule,
TuiModeModule,
FormModule,
],
providers: [InvalidService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent<T extends Record<string, any>> implements OnInit {
private readonly dialogFormService = inject(TuiDialogFormService)
private readonly formService = inject(FormService)
private readonly invalidService = inject(InvalidService)
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
POLYMORPHEUS_CONTEXT,
{ optional: true },
)
@Input() spec = this.context?.data.spec || {}
@Input() buttons = this.context?.data.buttons || []
@Input() patch = this.context?.data.patch || []
@Input() value?: T = this.context?.data.value
form = new FormGroup({})
ngOnInit() {
this.dialogFormService.markAsPristine()
this.form = this.formService.createForm(this.spec, this.value)
this.process(this.patch)
}
onReset() {
const { value } = this.form
this.form = this.formService.createForm(this.spec)
this.process(compare(this.form.value, value))
tuiMarkControlAsTouchedAndValidate(this.form)
this.markAsDirty()
}
async onClick(handler: Required<ActionButton<T>>['handler']) {
tuiMarkControlAsTouchedAndValidate(this.form)
this.invalidService.scrollIntoView()
if (this.form.valid && (await handler(this.form.value as T))) {
this.close()
}
}
markAsDirty() {
this.dialogFormService.markAsDirty()
}
close() {
this.context?.$implicit.complete()
}
private process(patch: Operation[]) {
patch.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
control.markAsDirty()
control.markAsTouched()
}
control.parent.markAsDirty()
control.parent.markAsTouched()
})
}
}

View File

@@ -1,30 +0,0 @@
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
import { ControlContainer, NgControl } from '@angular/forms'
import { InvalidService } from './invalid.service'
@Directive({
selector: 'form-control, form-array, form-object',
})
export class ControlDirective implements OnInit, OnDestroy {
private readonly invalidService = inject(InvalidService, { optional: true })
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
private readonly control =
inject(NgControl, { optional: true }) ||
inject(ControlContainer, { optional: true })
get invalid(): boolean {
return !!this.control?.invalid
}
scrollIntoView() {
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
}
ngOnInit() {
this.invalidService?.add(this)
}
ngOnDestroy() {
this.invalidService?.remove(this)
}
}

View File

@@ -1,35 +0,0 @@
import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { CT } from '@start9labs/start-sdk'
export abstract class Control<Spec extends CT.ValueSpec, Value> {
private readonly control: FormControlComponent<Spec, Value> =
inject(FormControlComponent)
get invalid(): boolean {
return this.control.touched && this.control.invalid
}
get spec(): Spec {
return this.control.spec
}
// TODO: Properly handle already set immutable value
get readOnly(): boolean {
return (
!!this.value && !!this.control.control?.pristine && this.control.immutable
)
}
get value(): Value | null {
return this.control.value
}
set value(value: Value | null) {
this.control.onInput(value)
}
onFocus(focused: boolean) {
this.control.onFocus(focused)
}
}

View File

@@ -1,58 +0,0 @@
<div class="label">
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[content]="spec | hint"
></tui-tooltip>
<button
tuiLink
type="button"
class="add"
[disabled]="!canAdd"
(click)="add()"
>
+ Add
</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-container *ngFor="let item of array.control.controls; let index = index">
<form-object
*ngIf="spec.spec.type === 'object'; else control"
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache : $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove"></ng-container>
</form-object>
<ng-template #control>
<form-control
class="control"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
[tuiTextfieldIcon]="remove"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
<ng-template #remove>
<button
tuiIconButton
type="button"
class="remove"
iconLeft="tuiIconTrash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
></button>
</ng-template>
</ng-container>

View File

@@ -1,50 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: block;
margin: 2rem 0;
}
.label {
display: flex;
font-size: 1.25rem;
font-weight: bold;
}
.add {
font-size: 1rem;
padding: 0 1rem;
margin-left: auto;
}
.object {
display: block;
position: relative;
&_open::after,
&:last-child::after {
opacity: 0;
}
&:after {
@include transition(opacity);
content: '';
position: absolute;
bottom: -0.5rem;
height: 1px;
left: 3rem;
right: 1rem;
background: var(--tui-clear);
}
}
.remove {
margin-left: auto;
pointer-events: auto;
}
.control {
display: block;
margin: 0.5rem 0;
}

View File

@@ -1,91 +0,0 @@
import { Component, HostBinding, inject, Input } from '@angular/core'
import { AbstractControl, FormArrayName } from '@angular/forms'
import { TUI_PARENT_ANIMATION, TuiDestroyService } from '@taiga-ui/cdk'
import {
TUI_ANIMATION_OPTIONS,
TuiDialogService,
tuiFadeIn,
tuiHeightCollapse,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { filter, takeUntil } from 'rxjs'
import { FormService } from 'src/app/services/form.service'
import { ERRORS } from '../form-group/form-group.component'
@Component({
selector: 'form-array',
templateUrl: './form-array.component.html',
styleUrls: ['./form-array.component.scss'],
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_ANIMATION],
providers: [TuiDestroyService],
})
export class FormArrayComponent {
@Input()
spec!: CT.ValueSpecList
@HostBinding('@tuiParentAnimation')
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
private warned = false
private readonly formService = inject(FormService)
private readonly dialogs = inject(TuiDialogService)
private readonly destroy$ = inject(TuiDestroyService)
get canAdd(): boolean {
return (
!this.spec.disabled &&
(!this.spec.maxLength ||
this.spec.maxLength >= this.array.control.controls.length)
)
}
add() {
if (!this.warned && this.spec.warning) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.addItem()
})
} else {
this.addItem()
}
this.warned = true
}
removeAt(index: number) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.removeItem(index)
})
}
private removeItem(index: number) {
this.open.delete(this.array.control.at(index))
this.array.control.removeAt(index)
}
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
}
}

View File

@@ -1,31 +0,0 @@
<tui-input
[maskito]="mask"
[tuiTextfieldCustomContent]="color"
[tuiTextfieldCleaner]="false"
[tuiHintContent]="spec | hint"
[readOnly]="readOnly"
[disabled]="!!spec.disabled"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input>
<ng-template #color>
<div class="wrapper" [style.color]="value">
<input
*ngIf="!readOnly && !spec.disabled"
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
<tui-icon
icon="tuiIconPaintLarge"
tuiAppearance="icon"
class="icon"
></tui-icon>
</div>
</ng-template>

View File

@@ -1,33 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.wrapper {
position: relative;
width: 1.5rem;
height: 1.5rem;
pointer-events: auto;
&::after {
content: '';
position: absolute;
height: 0.3rem;
width: 1.4rem;
bottom: 0.125rem;
background: currentColor;
border-radius: 0.125rem;
pointer-events: none;
}
}
.color {
@include fullsize();
opacity: 0;
}
.icon {
@include fullsize();
pointer-events: none;
input:hover + & {
opacity: 1;
}
}

View File

@@ -1,15 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { MaskitoOptions } from '@maskito/core'
@Component({
selector: 'form-color',
templateUrl: './form-color.component.html',
styleUrls: ['./form-color.component.scss'],
})
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
readonly mask: MaskitoOptions = {
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
}
}

View File

@@ -1,39 +0,0 @@
<ng-container [ngSwitch]="spec.type">
<form-color *ngSwitchCase="'color'"></form-color>
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
<form-number *ngSwitchCase="'number'"></form-number>
<form-select *ngSwitchCase="'select'"></form-select>
<form-text *ngSwitchCase="'text'"></form-text>
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
</ng-container>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-template
*ngIf="spec.warning || immutable"
#warning
let-completeWith="completeWith"
>
{{ spec.warning }}
<p *ngIf="immutable">This value cannot be changed once set!</p>
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
Rollback
</button>
<button
tuiButton
type="button"
appearance="flat"
size="s"
(click)="completeWith(false)"
>
Accept
</button>
</div>
</ng-template>

View File

@@ -1,11 +0,0 @@
:host {
display: block;
}
.buttons {
margin-top: 0.5rem;
:first-child {
margin-right: 0.5rem;
}
}

View File

@@ -1,71 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogContext,
TuiNotification,
} from '@taiga-ui/core'
import { filter, takeUntil } from 'rxjs'
import { CT } from '@start9labs/start-sdk'
import { ERRORS } from '../form-group/form-group.component'
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
@Component({
selector: 'form-control',
templateUrl: './form-control.component.html',
styleUrls: ['./form-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: FORM_CONTROL_PROVIDERS,
})
export class FormControlComponent<
T extends CT.ValueSpec,
V,
> extends AbstractTuiNullableControl<V> {
@Input()
spec!: T
@ViewChild('warning')
warning?: TemplateRef<TuiDialogContext<boolean>>
warned = false
focused = false
readonly order = ERRORS
private readonly alerts = inject(TuiAlertService)
get immutable(): boolean {
return 'immutable' in this.spec && this.spec.immutable
}
onFocus(focused: boolean) {
this.focused = focused
this.updateFocused(focused)
}
onInput(value: V | null) {
const previous = this.value
if (!this.warned && this.warning) {
this.alerts
.open<boolean>(this.warning, {
label: 'Warning',
status: TuiNotification.Warning,
hasCloseButton: false,
autoClose: false,
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.value = previous
})
}
this.warned = true
this.value = value === '' ? null : value
}
}

View File

@@ -1,25 +0,0 @@
import { forwardRef, Provider } from '@angular/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { FormControlComponent } from './form-control.component'
interface ValidatorsPatternError {
actualValue: string
requiredPattern: string | RegExp
}
export const FORM_CONTROL_PROVIDERS: Provider[] = [
{
provide: TUI_VALIDATION_ERRORS,
deps: [forwardRef(() => FormControlComponent)],
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
required: 'Required',
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
('patterns' in control.spec &&
control.spec.patterns.find(
({ regex }) => String(regex) === String(requiredPattern),
)?.description) ||
'Invalid format',
}),
},
]

View File

@@ -1,43 +0,0 @@
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
<tui-input-time
*ngSwitchCase="'time'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-time>
<tui-input-date
*ngSwitchCase="'date'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper : getLimit)[0] : min"
[max]="spec.max ? (spec.max | tuiMapper : getLimit)[0] : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date>
<tui-input-date-time
*ngSwitchCase="'datetime-local'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper : getLimit) : min"
[max]="spec.max ? (spec.max | tuiMapper : getLimit) : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date-time>
</ng-container>

View File

@@ -1,36 +0,0 @@
import { Component } from '@angular/core'
import {
TUI_FIRST_DAY,
TUI_LAST_DAY,
TuiDay,
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-datetime',
templateUrl: './form-datetime.component.html',
})
export class FormDatetimeComponent extends Control<
CT.ValueSpecDatetime,
string
> {
readonly min = TUI_FIRST_DAY
readonly max = TUI_LAST_DAY
@tuiPure
getTime(value: string | null) {
return value ? TuiTime.fromString(value) : null
}
getLimit(limit: string): [TuiDay, TuiTime] {
return [
TuiDay.jsonParse(limit.slice(0, 10)),
limit.length === 10
? new TuiTime(0, 0)
: TuiTime.fromString(limit.slice(-5)),
]
}
}

View File

@@ -1,30 +0,0 @@
<ng-container
*ngFor="let entry of spec | keyvalue : asIsOrder"
tuiMode="onDark"
[ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true"
>
<form-object
*ngSwitchCase="'object'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-object>
<form-union
*ngSwitchCase="'union'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-union>
<form-array
*ngSwitchCase="'list'"
[formArrayName]="entry.key"
[spec]="$any(entry.value)"
></form-array>
<form-control
*ngSwitchDefault
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
></form-control>
</ng-container>

View File

@@ -1,35 +0,0 @@
form-group .g-form-control:not(:first-child) {
margin-top: 1rem;
}
form-group .g-form-group {
position: relative;
padding-left: var(--tui-height-m);
&::before,
&::after {
content: '';
position: absolute;
background: var(--tui-clear);
}
&::before {
top: 0;
left: calc(1rem - 1px);
bottom: 0.5rem;
width: 2px;
}
&::after {
left: 0.75rem;
bottom: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
}
}
form-group tui-tooltip {
z-index: 1;
margin-left: 0.25rem;
}

View File

@@ -1,35 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
ViewEncapsulation,
} from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
export const ERRORS = [
'required',
'pattern',
'notNumber',
'numberNotInteger',
'numberNotInRange',
'listNotUnique',
'listNotInRange',
'listItemIssue',
]
@Component({
selector: 'form-group',
templateUrl: './form-group.component.html',
styleUrls: ['./form-group.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [FORM_GROUP_PROVIDERS],
})
export class FormGroupComponent {
@Input() spec: CT.InputSpec = {}
asIsOrder() {
return 0
}
}

View File

@@ -1,34 +0,0 @@
import { Provider, SkipSelf } from '@angular/core'
import {
TUI_ARROW_MODE,
tuiInputDateOptionsProvider,
tuiInputTimeOptionsProvider,
} from '@taiga-ui/kit'
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
import { ControlContainer } from '@angular/forms'
import { identity, of } from 'rxjs'
export const FORM_GROUP_PROVIDERS: Provider[] = [
{
provide: TUI_DEFAULT_ERROR_MESSAGE,
useValue: of('Unknown error'),
},
{
provide: ControlContainer,
deps: [[new SkipSelf(), ControlContainer]],
useFactory: identity,
},
{
provide: TUI_ARROW_MODE,
useValue: {
interactive: null,
disabled: null,
},
},
tuiInputDateOptionsProvider({
nativePicker: true,
}),
tuiInputTimeOptionsProvider({
nativePicker: true,
}),
]

View File

@@ -1,18 +0,0 @@
<tui-multi-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[editable]="false"
[disabledItemHandler]="disabledItemHandler"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<select
tuiSelect
multiple
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-multi-select>

View File

@@ -1,49 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { tuiPure } from '@taiga-ui/cdk'
import { invert } from '@start9labs/shared'
@Component({
selector: 'form-multiselect',
templateUrl: './form-multiselect.component.html',
})
export class FormMultiselectComponent extends Control<
CT.ValueSpecMultiselect,
readonly string[]
> {
private readonly inverted = invert(this.spec.values)
private readonly isDisabled = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
private readonly isExceedingLimit = (item: string) =>
!!this.spec.maxLength &&
this.selected.length >= this.spec.maxLength &&
!this.selected.includes(item)
readonly disabledItemHandler = (item: string): boolean =>
this.isDisabled(item) || this.isExceedingLimit(item)
readonly items = Object.values(this.spec.values)
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string[] {
return this.memoize(this.value)
}
set selected(value: string[]) {
this.value = Object.entries(this.spec.values)
.filter(([_, v]) => value.includes(v))
.map(([k]) => k)
}
@tuiPure
private memoize(value: null | readonly string[]): string[] {
return value?.map(key => this.spec.values[key]) || []
}
}

View File

@@ -1,18 +0,0 @@
<tui-input-number
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[tuiTextfieldPostfix]="spec.units || ''"
[pseudoInvalid]="invalid"
[precision]="Infinity"
[decimal]="spec.integer ? 'never' : 'not-zero'"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
[step]="spec.step || 0"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -1,11 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-number',
templateUrl: './form-number.component.html',
})
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
protected readonly Infinity = Infinity
}

View File

@@ -1,25 +0,0 @@
<h3 class="title" (click)="toggle()">
<button
tuiIconButton
size="s"
iconLeft="tuiIconChevronDown"
type="button"
class="button"
[class.button_open]="open"
[style.border-radius.%]="100"
[appearance]="invalid ? 'destructive' : 'secondary'"
></button>
<ng-content></ng-content>
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
(click.stop)="(0)"
></tui-tooltip>
</h3>
<tui-expand class="expand" [expanded]="open">
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
<form-group [spec]="spec.spec"></form-group>
</div>
</tui-expand>

View File

@@ -1,41 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
position: relative;
height: var(--tui-height-l);
display: flex;
align-items: center;
cursor: pointer;
font: var(--tui-font-text-l);
font-weight: bold;
margin: 0 0 -0.75rem;
}
.button {
@include transition(transform);
margin-right: 1rem;
&_open {
transform: rotate(180deg);
}
}
.expand {
align-self: stretch;
}
.g-form-group {
padding-top: 0.75rem;
&_invalid::before,
&_invalid::after {
background: var(--tui-error-bg);
}
}

View File

@@ -1,38 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormObjectComponent {
@Input()
spec!: CT.ValueSpecObject
@Input()
open = false
@Output()
readonly openChange = new EventEmitter<boolean>()
private readonly container = inject(ControlContainer)
get invalid() {
return !this.container.valid && this.container.touched
}
toggle() {
this.open = !this.open
this.openChange.emit(this.open)
}
}

View File

@@ -1,18 +0,0 @@
<tui-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[tuiTextfieldCleaner]="!spec.required"
[pseudoInvalid]="invalid"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<select
tuiSelect
[placeholder]="spec.name"
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-select>

View File

@@ -1,30 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { invert } from '@start9labs/shared'
import { Control } from '../control'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
})
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
private readonly inverted = invert(this.spec.values)
readonly items = Object.values(this.spec.values)
readonly disabledItemHandler = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string | null {
return (this.value && this.spec.values[this.value]) || null
}
set selected(value: string | null) {
this.value = (value && this.inverted[value]) || null
}
}

View File

@@ -1,44 +0,0 @@
<tui-input
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input
tuiTextfield
[class.masked]="spec.masked && masked"
[placeholder]="spec.placeholder || ''"
[attr.minLength]="spec.minLength"
[attr.maxLength]="spec.maxLength"
[attr.inputmode]="spec.inputmode"
/>
</tui-input>
<ng-template #toggle>
<button
*ngIf="spec.generate"
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconLeft="tuiIconRefreshCcw"
(click)="generate()"
></button>
<button
*ngIf="spec.masked"
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>

View File

@@ -1,8 +0,0 @@
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}

View File

@@ -1,16 +0,0 @@
import { Component } from '@angular/core'
import { CT, utils } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-text',
templateUrl: './form-text.component.html',
styleUrls: ['./form-text.component.scss'],
})
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
masked = true
generate() {
this.value = utils.getDefaultString(this.spec.generate || '')
}
}

View File

@@ -1,15 +0,0 @@
<tui-textarea
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[expandable]="true"
[rows]="6"
[maxLength]="spec.maxLength"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
</tui-textarea>

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-textarea',
templateUrl: './form-textarea.component.html',
})
export class FormTextareaComponent extends Control<
CT.ValueSpecTextarea,
string
> {}

View File

@@ -1,11 +0,0 @@
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[tuiHintContent]="spec | hint"
></tui-tooltip>
<tui-toggle
size="l"
[disabled]="!!spec.disabled || readOnly"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
></tui-toggle>

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-toggle',
templateUrl: './form-toggle.component.html',
host: { class: 'g-toggle' },
})
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}

View File

@@ -1,11 +0,0 @@
<form-control
[spec]="selectSpec"
formControlName="selection"
(tuiValueChanges)="onUnion($event)"
></form-control>
<tui-elastic-container class="g-form-group" formGroupName="value">
<form-group
class="group"
[spec]="(union && spec.variants[union].spec) || {}"
></form-group>
</tui-elastic-container>

View File

@@ -1,8 +0,0 @@
:host {
display: block;
}
.group {
display: block;
margin-top: 1rem;
}

View File

@@ -1,55 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
} from '@angular/core'
import { ControlContainer, FormGroupName } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
import { FormService } from 'src/app/services/form.service'
import { tuiPure } from '@taiga-ui/cdk'
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-union.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [
{
provide: ControlContainer,
useExisting: FormGroupName,
},
],
})
export class FormUnionComponent implements OnChanges {
@Input()
spec!: CT.ValueSpecUnion
selectSpec!: CT.ValueSpecSelect
private readonly form = inject(FormGroupName)
private readonly formService = inject(FormService)
get union(): string {
return this.form.value.selection
}
@tuiPure
onUnion(union: string) {
this.form.control.setControl(
'value',
this.formService.getFormGroup(
union ? this.spec.variants[union].spec : {},
),
{
emitEvent: false,
},
)
}
ngOnChanges() {
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
if (this.union) this.onUnion(this.union)
}
}

View File

@@ -1,107 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MaskitoModule } from '@maskito/angular'
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
import {
TuiErrorModule,
TuiExpandModule,
TuiHintModule,
TuiLinkModule,
TuiModeModule,
TuiTextfieldControllerModule,
TuiTooltipModule,
} from '@taiga-ui/core'
import {
TuiAppearanceModule,
TuiButtonModule,
TuiIconModule,
} from '@taiga-ui/experimental'
import {
TuiElasticContainerModule,
TuiFieldErrorPipeModule,
TuiInputDateModule,
TuiInputDateTimeModule,
TuiInputFilesModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputTimeModule,
TuiMultiSelectModule,
TuiPromptModule,
TuiSelectModule,
TuiTagModule,
TuiTextareaModule,
TuiToggleModule,
} from '@taiga-ui/kit'
import { FormGroupComponent } from './form-group/form-group.component'
import { FormTextComponent } from './form-text/form-text.component'
import { FormToggleComponent } from './form-toggle/form-toggle.component'
import { FormTextareaComponent } from './form-textarea/form-textarea.component'
import { FormNumberComponent } from './form-number/form-number.component'
import { FormSelectComponent } from './form-select/form-select.component'
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
import { FormUnionComponent } from './form-union/form-union.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormArrayComponent } from './form-array/form-array.component'
import { FormControlComponent } from './form-control/form-control.component'
import { MustachePipe } from './mustache.pipe'
import { ControlDirective } from './control.directive'
import { FormColorComponent } from './form-color/form-color.component'
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
import { HintPipe } from './hint.pipe'
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputFilesModule,
TuiTextareaModule,
TuiSelectModule,
TuiMultiSelectModule,
TuiToggleModule,
TuiTooltipModule,
TuiHintModule,
TuiModeModule,
TuiTagModule,
TuiButtonModule,
TuiExpandModule,
TuiTextfieldControllerModule,
TuiLinkModule,
TuiPromptModule,
TuiErrorModule,
TuiFieldErrorPipeModule,
TuiValueChangesModule,
TuiElasticContainerModule,
MaskitoModule,
TuiIconModule,
TuiAppearanceModule,
TuiInputDateModule,
TuiInputTimeModule,
TuiInputDateTimeModule,
TuiMapperPipeModule,
],
declarations: [
FormGroupComponent,
FormControlComponent,
FormColorComponent,
FormDatetimeComponent,
FormTextComponent,
FormToggleComponent,
FormTextareaComponent,
FormNumberComponent,
FormSelectComponent,
FormMultiselectComponent,
FormUnionComponent,
FormObjectComponent,
FormArrayComponent,
MustachePipe,
HintPipe,
ControlDirective,
],
exports: [FormGroupComponent],
})
export class FormModule {}

View File

@@ -1,21 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
@Pipe({
name: 'hint',
})
export class HintPipe implements PipeTransform {
transform(spec: CT.ValueSpec): string {
const hint = []
if (spec.description) {
hint.push(spec.description)
}
if ('disabled' in spec && typeof spec.disabled === 'string') {
hint.push(`Disabled: ${spec.disabled}`)
}
return hint.join('\n\n')
}
}

View File

@@ -1,19 +0,0 @@
import { Injectable } from '@angular/core'
import { ControlDirective } from './control.directive'
@Injectable()
export class InvalidService {
private readonly controls: ControlDirective[] = []
scrollIntoView() {
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
}
add(control: ControlDirective) {
this.controls.push(control)
}
remove(control: ControlDirective) {
this.controls.splice(this.controls.indexOf(control), 1)
}
}

View File

@@ -1,12 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
const Mustache = require('mustache')
@Pipe({
name: 'mustache',
})
export class MustachePipe implements PipeTransform {
transform(value: any, displayAs: string): string {
return displayAs && Mustache.render(displayAs, value)
}
}

View File

@@ -27,7 +27,7 @@ export class NotificationsToastComponent {
readonly visible$: Observable<boolean> = merge(
this.dismiss$,
inject(PatchDB<DataModel>)
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'unreadNotifications', 'count')
.pipe(
pairwise(),

View File

@@ -1,7 +1,7 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { SwUpdate } from '@angular/service-worker'
import { Emver, LoadingService } from '@start9labs/shared'
import { Exver, LoadingService } from '@start9labs/shared'
import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiDialog } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
@@ -47,16 +47,16 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
export class RefreshAlertComponent {
private readonly updates = inject(SwUpdate)
private readonly loader = inject(LoadingService)
private readonly emver = inject(Emver)
private readonly exver = inject(Exver)
private readonly config = inject(ConfigService)
private readonly dismiss$ = new Subject<boolean>()
readonly show$ = merge(
this.dismiss$,
inject(PatchDB<DataModel>)
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'version')
.pipe(
map(version => !!this.emver.compare(this.config.version, version)),
map(version => !!this.exver.compareExver(this.config.version, version)),
endWith(false),
),
).pipe(debounceTime(0))

View File

@@ -53,7 +53,7 @@ export class UpdateToastComponent {
readonly visible$: Observable<boolean> = merge(
this.dismiss$,
inject(PatchDB<DataModel>)
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'statusInfo', 'updated')
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false)),
)

View File

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { BackupServerSelectModal } from './backup-server-select.page'
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
@NgModule({
declarations: [BackupServerSelectModal],
imports: [CommonModule, FormsModule, IonicModule, AppRecoverSelectPageModule],
exports: [BackupServerSelectModal],
})
export class BackupServerSelectModule {}

View File

@@ -1,35 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Server Backup</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item-group>
<ion-item
*ngFor="let server of target.entry.startOs | keyvalue"
button
(click)="presentModalPassword(server.key, server.value)"
>
<ion-label>
<h2>
<b>Local Hostname</b>
: {{ server.value.hostname }}.local
</h2>
<h2>
<b>StartOS Version</b>
: {{ server.value.version }}
</h2>
<h2>
<b>Created</b>
: {{ server.value.timestamp | date : 'medium' }}
</h2>
</ion-label>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -1,118 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular'
import * as argon2 from '@start9labs/argon2'
import {
ErrorService,
LoadingService,
StartOSDiskInfo,
} from '@start9labs/shared'
import {
PasswordPromptComponent,
PromptOptions,
} from 'src/app/modals/password-prompt.component'
import {
BackupInfo,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import { AppRecoverSelectPage } from '../app-recover-select/app-recover-select.page'
@Component({
selector: 'backup-server-select',
templateUrl: 'backup-server-select.page.html',
styleUrls: ['backup-server-select.page.scss'],
})
export class BackupServerSelectModal {
@Input() target!: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
constructor(
private readonly modalCtrl: ModalController,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly navCtrl: NavController,
private readonly errorService: ErrorService,
) {}
dismiss() {
this.modalCtrl.dismiss()
}
async presentModalPassword(
serverId: string,
{ passwordHash }: StartOSDiskInfo,
): Promise<void> {
const options: PromptOptions = {
title: 'Password Required',
message:
'Enter the password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
label: 'Decrypt Backup',
placeholder: 'Enter password',
buttonText: 'Next',
}
const modal = await this.modalCtrl.create({
component: PasswordPromptComponent,
componentProps: { options },
canDismiss: async password => {
if (password === null) {
return true
}
try {
argon2.verify(passwordHash!, password)
await this.restoreFromBackup(serverId, password)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
}
},
})
modal.present()
}
private async restoreFromBackup(
serverId: string,
password: string,
): Promise<void> {
const loader = this.loader.open('Decrypting drive...').subscribe()
try {
const backupInfo = await this.api.getBackupInfo({
targetId: this.target.id,
serverId,
password,
})
this.presentModalSelect(serverId, backupInfo, password)
} finally {
loader.unsubscribe()
}
}
private async presentModalSelect(
serverId: string,
backupInfo: BackupInfo,
password: string,
): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
targetId: this.target.id,
serverId,
backupInfo,
password,
},
presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage,
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalCtrl.dismiss(undefined, 'success')
this.navCtrl.navigateRoot('/services')
}
})
await modal.present()
}
}

View File

@@ -1,104 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
import { isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'config-dep',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ package }}
</h3>
The following modifications have been made to {{ package }} to satisfy
{{ dep }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
To accept these modifications, click "Save".
</tui-notification>
`,
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfigDepComponent implements OnChanges {
@Input()
package = ''
@Input()
dep = ''
@Input()
original: object = {}
@Input()
value: object = {}
diff: string[] = []
ngOnChanges() {
this.diff = compare(this.original, this.value).map(
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
)
}
private getPath(operation: Operation): string {
const path = operation.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (tuiIsNumber(path[path.length - 1])) {
path.pop()
}
return path.join(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -1,261 +0,0 @@
import { CommonModule } from '@angular/common'
import { Component, Inject, ViewChild } from '@angular/core'
import {
ErrorService,
getErrorMessage,
isEmptyObject,
LoadingService,
} from '@start9labs/shared'
import { CT, T } from '@start9labs/start-sdk'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiDialogContext,
TuiDialogService,
TuiLoaderModule,
TuiModeModule,
TuiNotificationModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { ActionButton, FormComponent } from 'src/app/components/form.component'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import {
getAllPackages,
getManifest,
getPackage,
} from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { Breakages } from 'src/app/services/api/api.types'
import { DependentInfo } from 'src/app/types/dependent-info'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
template: `
<tui-loader
*ngIf="loadingText"
size="l"
[textContent]="loadingText"
></tui-loader>
<tui-notification
*ngIf="!loadingText && (loadingError || !pkg)"
status="error"
>
<div [innerHTML]="loadingError"></div>
</tui-notification>
<ng-container
*ngIf="
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
"
>
<tui-notification *ngIf="success" status="success">
{{ manifest.title }} has been automatically configured with recommended
defaults. Make whatever changes you want, then click "Save".
</tui-notification>
<config-dep
*ngIf="dependentInfo && value && original"
[package]="manifest.title"
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
></config-dep>
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
No config options for {{ manifest.title }} {{ manifest.version }}.
</tui-notification>
<app-form
tuiMode="onDark"
[spec]="spec"
[value]="value || {}"
[buttons]="buttons"
[patch]="patch"
>
<button
tuiButton
appearance="flat"
type="reset"
[style.margin-right]="'auto'"
>
Reset Defaults
</button>
</app-form>
</ng-container>
`,
styles: [
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormComponent,
TuiLoaderModule,
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ConfigDepComponent,
UiPipeModule,
],
providers: [InvalidService],
})
export class ConfigModal {
@ViewChild(FormComponent)
private readonly form?: FormComponent<Record<string, any>>
readonly pkgId = this.context.data.pkgId
readonly dependentInfo = this.context.data.dependentInfo
loadingError = ''
loadingText = this.dependentInfo
? `Setting properties to accommodate ${this.dependentInfo.title}`
: 'Loading Config'
pkg?: PackageDataEntry
spec: CT.InputSpec = {}
patch: Operation[] = []
buttons: ActionButton<any>[] = [
{
text: 'Save',
handler: value => this.save(value),
},
]
original: object | null = null
value: object | null = null
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, PackageConfigData>,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly patchDb: PatchDB<DataModel>,
) {}
get success(): boolean {
return (
!!this.form &&
!this.form.form.dirty &&
!this.original &&
!this.pkg?.status?.configured
)
}
async ngOnInit() {
try {
this.pkg = await getPackage(this.patchDb, this.pkgId)
if (!this.pkg) {
this.loadingError = 'This service does not exist'
return
}
if (this.dependentInfo) {
const depConfig = await this.embassyApi.dryConfigureDependency({
dependencyId: this.pkgId,
dependentId: this.dependentInfo.id,
})
this.original = depConfig.oldConfig
this.value = depConfig.newConfig || this.original
this.spec = depConfig.spec
this.patch = compare(this.original, this.value)
} else {
const { config, spec } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = config
this.value = config
this.spec = spec
}
} catch (e: any) {
this.loadingError = String(getErrorMessage(e))
} finally {
this.loadingText = ''
}
}
private async save(config: any) {
const loader = new Subscription()
try {
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async configureDeps(
config: Record<string, any>,
loader: Subscription,
) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
loader.unsubscribe()
loader.closed = false
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
}
}
private async configure(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
this.context.$implicit.complete()
}
private async approveBreakages(breakages: T.PackageId[]): Promise<boolean> {
const packages = await getAllPackages(this.patchDb)
const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const content = `${message}${breakages.map(
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
)}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
return firstValueFrom(
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
)
}
}

View File

@@ -25,9 +25,9 @@ import { StoreIconComponent } from './store-icon.component'
</div>
<tui-icon
*ngIf="registry.selected; else content"
icon="tuiIconCheck"
icon="@tui.check"
[style.color]="'var(--tui-positive)'"
></tui-icon>
/>
<ng-template #content><ng-content></ng-content></ng-template>
`,
styles: [':host { border-radius: 0.25rem; width: stretch; }'],

View File

@@ -1,94 +0,0 @@
import {
AfterViewInit,
Component,
ElementRef,
Input,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { IonicModule, ModalController } from '@ionic/angular'
import { TuiTextfieldComponent } from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/kit'
export interface PromptOptions {
title: string
message: string
label: string
placeholder: string
buttonText: string
}
@Component({
standalone: true,
template: `
<ion-header>
<ion-toolbar>
<ion-title>{{ options.title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="cancel()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>{{ options.message }}</p>
<p>
<tui-input-password [(ngModel)]="password" (keydown.enter)="confirm()">
{{ options.label }}
<input tuiTextfield [placeholder]="options.placeholder" />
</tui-input-password>
</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button
class="ion-padding-end"
slot="end"
color="dark"
(click)="cancel()"
>
Cancel
</ion-button>
<ion-button
class="ion-padding-end"
slot="end"
color="primary"
strong="true"
[disabled]="!password"
(click)="confirm()"
>
{{ options.buttonText }}
</ion-button>
</ion-toolbar>
</ion-footer>
`,
imports: [IonicModule, FormsModule, TuiInputPasswordModule],
})
export class PasswordPromptComponent implements AfterViewInit {
@ViewChild(TuiTextfieldComponent, { read: ElementRef })
input?: ElementRef<HTMLInputElement>
@Input()
options!: PromptOptions
password = ''
constructor(private modalCtrl: ModalController) {}
ngAfterViewInit() {
setTimeout(() => {
this.input?.nativeElement.focus({ preventScroll: true })
}, 300)
}
cancel() {
return this.modalCtrl.dismiss(null, 'cancel')
}
confirm() {
return this.modalCtrl.dismiss(this.password, 'confirm')
}
}

View File

@@ -1,123 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
@Component({
standalone: true,
template: `
<p>{{ options.message }}</p>
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
<form (ngSubmit)="submit(value.trim())">
<tui-input
tuiAutoFocus
[tuiTextfieldLabelOutside]="!options.label"
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="value"
>
{{ options.label }}
<span *ngIf="options.required !== false && options.label">*</span>
<input
tuiTextfield
[class.masked]="options.useMask && masked && value"
[placeholder]="options.placeholder || ''"
/>
</tui-input>
<footer class="g-buttons">
<button
tuiButton
type="button"
appearance="secondary"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="!value && options.required !== false">
{{ options.buttonText || 'Submit' }}
</button>
</footer>
</form>
<ng-template #toggle>
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>
`,
styles: [
`
.warning {
color: var(--tui-warning-fill);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiButtonModule,
TuiTextfieldControllerModule,
TuiAutoFocusModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<string, PromptOptions>,
) {}
get options(): PromptOptions {
return this.context.data
}
cancel() {
this.context.$implicit.complete()
}
submit(value: string) {
if (value || !this.options.required) {
this.context.$implicit.next(value)
}
}
}
export const PROMPT = new PolymorpheusComponent(PromptModal)
export interface PromptOptions {
message: string
label?: string
warning?: string
buttonText?: string
placeholder?: string
required?: boolean
useMask?: boolean
initialValue?: string | null
}

View File

@@ -1,21 +0,0 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
const ROUTES: Routes = [
{
path: '',
loadChildren: () =>
import('./home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadChildren: () =>
import('./logs/logs.module').then(m => m.LogsPageModule),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
exports: [RouterModule],
})
export class DiagnosticModule {}

View File

@@ -1,222 +0,0 @@
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { Component, Inject } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { DiagnosticService } from '../services/diagnostic.service'
========
import { Component } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { LoadingService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
@Component({
selector: 'diagnostic-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
restarted = false
error?: {
code: number
problem: string
solution: string
details?: string
}
constructor(
private readonly loader: LoadingService,
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private readonly api: DiagnosticService,
private readonly dialogs: TuiDialogService,
@Inject(WINDOW) private readonly window: Window,
========
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
) {}
async ngOnInit() {
try {
const error = await this.api.diagnosticGetError()
// incorrect drive
if (error.code === 15) {
this.error = {
code: 15,
problem: 'Unknown storage drive detected',
solution:
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
details: error.data?.details,
}
// no drive
} else if (error.code === 20) {
this.error = {
code: 20,
problem: 'Storage drive not found',
solution:
'Insert your StartOS storage drive and click RESTART SERVER below.',
details: error.data?.details,
}
// drive corrupted
} else if (error.code === 25) {
this.error = {
code: 25,
problem:
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
solution:
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
details: error.data?.details,
}
// filesystem I/O error - disk needs repair
} else if (error.code === 2) {
this.error = {
code: 2,
problem: 'Filesystem I/O error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
// disk management error - disk needs repair
} else if (error.code === 48) {
this.error = {
code: 48,
problem: 'Disk management error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
} else {
this.error = {
code: error.code,
problem: error.message,
solution: 'Please contact support.',
details: error.data?.details,
}
}
} catch (e) {
console.error(e)
}
}
async restart(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
async forgetDrive(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticForgetDrive()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
async presentAlertSystemRebuild() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Rebuild',
content:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
})
}
========
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
async presentAlertRepairDisk() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Repair',
content:
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.repairDisk()
} catch (e) {
console.error(e)
}
})
}
refreshPage(): void {
this.window.location.reload()
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private async systemRebuild(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
private async repairDisk(): Promise<void> {
const loader = this.loader.open('').subscribe()
========
private async repairDisk(): Promise<void> {
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRepairDisk()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,24 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TuiProgressModule } from '@taiga-ui/kit'
import { LogsModule } from 'src/app/pages/init/logs/logs.module'
import { InitPage } from './init.page'
const routes: Routes = [
{
path: '',
component: InitPage,
},
]
@NgModule({
imports: [
CommonModule,
LogsModule,
TuiProgressModule,
RouterModule.forChild(routes),
],
declarations: [InitPage],
})
export class InitPageModule {}

View File

@@ -1,18 +0,0 @@
<section *ngIf="progress$ | async as progress">
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
Initializing StartOS
</h1>
<div *ngIf="progress.total">
Progress: {{ (progress.total * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
class="progress"
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress.total"
></progress>
<p [innerHTML]="progress.message"></p>
</section>
<logs-window></logs-window>

View File

@@ -1,23 +0,0 @@
section {
border-radius: 0.25rem;
padding: 1rem;
margin: 1.5rem;
text-align: center;
/* TODO: Theme */
background: #e0e0e0;
color: #333;
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
}
logs-window {
display: flex;
flex-direction: column;
height: 18rem;
padding: 1rem;
margin: 0 1.5rem auto;
text-align: left;
overflow: hidden;
border-radius: 2rem;
/* TODO: Theme */
background: #181818;
}

View File

@@ -1,11 +0,0 @@
import { Component, inject } from '@angular/core'
import { InitService } from 'src/app/pages/init/init.service'
@Component({
selector: 'init-page',
templateUrl: 'init.page.html',
styleUrls: ['init.page.scss'],
})
export class InitPage {
readonly progress$ = inject(InitService)
}

View File

@@ -1,90 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { ErrorService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
defer,
EMPTY,
from,
map,
Observable,
startWith,
switchMap,
tap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { StateService } from 'src/app/services/state.service'
interface MappedProgress {
readonly total: number | null
readonly message: string
}
@Injectable({ providedIn: 'root' })
export class InitService extends Observable<MappedProgress> {
private readonly state = inject(StateService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly progress$ = defer(() =>
from(this.api.initGetProgress()),
).pipe(
switchMap(({ guid, progress }) =>
this.api
.openWebsocket$<T.FullProgress>(guid, {})
.pipe(startWith(progress)),
),
map(({ phases, overall }) => {
return {
total: getOverallDecimal(overall),
message: phases
.filter(
(
p,
): p is {
name: string
progress: {
done: number
total: number | null
}
} => p.progress !== true && p.progress !== null,
)
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
.join(', '),
}
}),
tap(({ total }) => {
if (total === 1) {
this.state.syncState()
}
}),
catchError(e => {
console.error(e)
return EMPTY
}),
)
constructor() {
super(subscriber => this.progress$.subscribe(subscriber))
}
}
function getOverallDecimal(progress: T.Progress): number {
if (progress === true) {
return 1
} else if (!progress || !progress.total) {
return 0
} else {
return progress.total && progress.done / progress.total
}
}
function getPhaseBytes(
progress:
| false
| {
done: number
total: number | null
},
): string {
return progress === false ? '' : `: (${progress.done}/${progress.total})`
}

View File

@@ -1,33 +0,0 @@
import { Component, ElementRef, inject } from '@angular/core'
import { INTERSECTION_ROOT } from '@ng-web-apis/intersection-observer'
import { LogsService } from 'src/app/pages/init/logs/logs.service'
@Component({
selector: 'logs-window',
templateUrl: 'logs.template.html',
styles: [
`
pre {
margin: 0;
}
`,
],
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
})
export class LogsComponent {
readonly logs$ = inject(LogsService)
scroll = true
scrollTo(bottom: HTMLElement) {
if (this.scroll) bottom.scrollIntoView()
}
onBottom(entries: readonly IntersectionObserverEntry[]) {
this.scroll = entries[entries.length - 1].isIntersecting
}
}

View File

@@ -1,20 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IntersectionObserverModule } from '@ng-web-apis/intersection-observer'
import { MutationObserverModule } from '@ng-web-apis/mutation-observer'
import { TuiScrollbarModule } from '@taiga-ui/core'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { LogsComponent } from './logs.component'
@NgModule({
imports: [
CommonModule,
MutationObserverModule,
IntersectionObserverModule,
NgDompurifyModule,
TuiScrollbarModule,
],
declarations: [LogsComponent],
exports: [LogsComponent],
})
export class LogsModule {}

View File

@@ -1,51 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { Log, toLocalIsoString } from '@start9labs/shared'
import {
bufferTime,
defer,
filter,
map,
Observable,
scan,
switchMap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
var Convert = require('ansi-to-html')
var convert = new Convert({
newline: true,
bg: 'transparent',
colors: {
4: 'Cyan',
},
escapeXML: true,
})
function convertAnsi(entries: readonly any[]): string {
return entries
.map(
({ timestamp, message }) =>
`<b style="color: #FFF">${toLocalIsoString(
new Date(timestamp),
)}</b>&nbsp;&nbsp;${convert.toHtml(message)}`,
)
.join('<br />')
}
@Injectable({ providedIn: 'root' })
export class LogsService extends Observable<readonly string[]> {
private readonly api = inject(ApiService)
private readonly log$ = defer(() =>
this.api.initFollowLogs({ boot: 0 }),
).pipe(
switchMap(({ guid }) => this.api.openWebsocket$<Log>(guid, {})),
bufferTime(500),
filter(logs => !!logs.length),
map(convertAnsi),
scan((logs: readonly string[], log) => [...logs, log], []),
)
constructor() {
super(subscriber => this.log$.subscribe(subscriber))
}
}

Some files were not shown because too many files have changed in this diff Show More