mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
fix: fix build after minor merged into major
This commit is contained in:
1
web/package-lock.json
generated
1
web/package-lock.json
generated
@@ -31,7 +31,6 @@
|
|||||||
"@taiga-ui/cdk": "4.0.0-rc.7",
|
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||||
"@taiga-ui/core": "4.0.0-rc.7",
|
"@taiga-ui/core": "4.0.0-rc.7",
|
||||||
"@taiga-ui/event-plugins": "^4.0.1",
|
"@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/icons": "4.0.0-rc.7",
|
||||||
"@taiga-ui/kit": "4.0.0-rc.7",
|
"@taiga-ui/kit": "4.0.0-rc.7",
|
||||||
"@taiga-ui/layout": "4.0.0-rc.7",
|
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
"@taiga-ui/cdk": "4.0.0-rc.7",
|
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||||
"@taiga-ui/core": "4.0.0-rc.7",
|
"@taiga-ui/core": "4.0.0-rc.7",
|
||||||
"@taiga-ui/event-plugins": "^4.0.1",
|
"@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/icons": "4.0.0-rc.7",
|
||||||
"@taiga-ui/kit": "4.0.0-rc.7",
|
"@taiga-ui/kit": "4.0.0-rc.7",
|
||||||
"@taiga-ui/layout": "4.0.0-rc.7",
|
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"@angular/core": ">=13.2.0",
|
"@angular/core": ">=13.2.0",
|
||||||
"@start9labs/shared": ">=0.3.2",
|
"@start9labs/shared": ">=0.3.2",
|
||||||
"@taiga-ui/cdk": "4.0.0-rc.6",
|
"@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",
|
"@tinkoff/ng-dompurify": ">=4.0.0",
|
||||||
"fuse.js": "^6.4.6"
|
"fuse.js": "^6.4.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<button
|
<button
|
||||||
*ngFor="let cat of categories || ['', '', '', '', '', '']"
|
*ngFor="let cat of categories || fallback | keyvalue"
|
||||||
(click)="switchCategory(cat)"
|
(click)="switchCategory(cat.key)"
|
||||||
[class.category_selected]="cat === category"
|
[class.category_selected]="cat.key === category"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="category-wrapper"
|
class="category-wrapper"
|
||||||
[class.tui-skeleton]="!categories"
|
[class.tui-skeleton]="!categories"
|
||||||
[class.tui-skeleton_rounded]="!categories"
|
[class.tui-skeleton_rounded]="!categories"
|
||||||
>
|
>
|
||||||
<tui-icon tuiAppearance="icon" icon="{{ determineIcon(cat) }}"></tui-icon>
|
<tui-icon tuiAppearance="icon" [icon]="determineIcon(cat.key)" />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="category-title"
|
class="category-title"
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
[class.tui-skeleton_rounded]="!categories"
|
[class.tui-skeleton_rounded]="!categories"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
cat === 'ai'
|
cat.key === 'ai'
|
||||||
? (cat | uppercase)
|
? (cat.key | uppercase)
|
||||||
: (cat | titlecase) || 'Loading category...'
|
: (cat.value.name | titlecase) || 'Loading category...'
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { T } from '@start9labs/start-sdk'
|
|||||||
})
|
})
|
||||||
export class CategoriesComponent {
|
export class CategoriesComponent {
|
||||||
@Input()
|
@Input()
|
||||||
categories!: Map<string, T.Category>
|
categories?: Record<string, T.Category>
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
category = ''
|
category = ''
|
||||||
@@ -23,6 +23,14 @@ export class CategoriesComponent {
|
|||||||
@Output()
|
@Output()
|
||||||
readonly categoryChange = new EventEmitter<string>()
|
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 {
|
switchCategory(category: string): void {
|
||||||
this.category = category
|
this.category = category
|
||||||
this.categoryChange.emit(category)
|
this.categoryChange.emit(category)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class ItemComponent {
|
|||||||
const iconUrl = new URL(this.pkg.icon)
|
const iconUrl = new URL(this.pkg.icon)
|
||||||
return iconUrl.href
|
return iconUrl.href
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
|
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
<div class="background-border box-shadow-lg shadow-color-light">
|
<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">
|
<div class="box-container">
|
||||||
<h2 class="additional-detail-title">About</h2>
|
<h2 class="additional-detail-title">About</h2>
|
||||||
<p>
|
<p>{{ pkg.description.long }}</p>
|
||||||
{{ pkg.manifest.description.long }}
|
<a
|
||||||
</p>
|
*ngIf="pkg.marketingSite as url"
|
||||||
|
tuiButton
|
||||||
|
iconEnd="@tui.external-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
[href]="url"
|
||||||
|
>
|
||||||
|
View website
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { MarketplacePkg } from '../../../types'
|
||||||
import { ModalController } from '@ionic/angular'
|
|
||||||
import { ReleaseNotesComponent } from '../../../modals/release-notes/release-notes.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'marketplace-about',
|
selector: 'marketplace-about',
|
||||||
@@ -10,17 +15,14 @@ import { ReleaseNotesComponent } from '../../../modals/release-notes/release-not
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AboutComponent {
|
export class AboutComponent {
|
||||||
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
pkg!: MarketplacePkg
|
pkg!: MarketplacePkg
|
||||||
|
|
||||||
constructor(private readonly modalCtrl: ModalController) {}
|
async onPast() {
|
||||||
|
this.dialogs
|
||||||
async presentModalNotes() {
|
.open(RELEASE_NOTES, { label: 'Past Release Notes' })
|
||||||
const modal = await this.modalCtrl.create({
|
.subscribe()
|
||||||
componentProps: { pkg: this.pkg },
|
|
||||||
component: ReleaseNotesComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
await modal.present()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { TuiButton } from '@taiga-ui/core'
|
||||||
import { TuiTagModule } from '@taiga-ui/legacy'
|
import { TuiTagModule } from '@taiga-ui/legacy'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { AboutComponent } from './about.component'
|
import { AboutComponent } from './about.component'
|
||||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||||
import { SafeLinksDirective } from '@start9labs/shared'
|
import { MarkdownPipeModule, SafeLinksDirective } from '@start9labs/shared'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -13,6 +14,8 @@ import { SafeLinksDirective } from '@start9labs/shared'
|
|||||||
TuiTagModule,
|
TuiTagModule,
|
||||||
NgDompurifyModule,
|
NgDompurifyModule,
|
||||||
SafeLinksDirective,
|
SafeLinksDirective,
|
||||||
|
MarkdownPipeModule,
|
||||||
|
TuiButton,
|
||||||
],
|
],
|
||||||
declarations: [AboutComponent],
|
declarations: [AboutComponent],
|
||||||
exports: [AboutComponent],
|
exports: [AboutComponent],
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<div class="detail-container">
|
<div class="detail-container">
|
||||||
<!-- release date -->
|
<!-- release date -->
|
||||||
<marketplace-additional-item
|
<marketplace-additional-item
|
||||||
*ngIf="pkg.publishedAt as published"
|
*ngIf="pkg.s9pk.publishedAt as published"
|
||||||
[data]="(published | date: 'medium')!"
|
[data]="(published | date: 'medium')!"
|
||||||
label="Released"
|
label="Released"
|
||||||
icon=""
|
icon=""
|
||||||
></marketplace-additional-item>
|
/>
|
||||||
<!-- git hash -->
|
<!-- git hash -->
|
||||||
<marketplace-additional-item
|
<marketplace-additional-item
|
||||||
*ngIf="pkg.gitHash as gitHash; else noHash"
|
*ngIf="pkg.gitHash as gitHash; else noHash"
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
label="Git Hash"
|
label="Git Hash"
|
||||||
icon="@tui.copy"
|
icon="@tui.copy"
|
||||||
class="item-copy"
|
class="item-copy"
|
||||||
></marketplace-additional-item>
|
/>
|
||||||
<ng-template #noHash>
|
<ng-template #noHash>
|
||||||
<div class="item-padding">
|
<div class="item-padding">
|
||||||
<label tuiTitle>
|
<label tuiTitle>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
label="License"
|
label="License"
|
||||||
icon="@tui.chevron-right"
|
icon="@tui.chevron-right"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-item>
|
/>
|
||||||
<!-- instructions -->
|
<!-- instructions -->
|
||||||
<marketplace-additional-item
|
<marketplace-additional-item
|
||||||
(click)="presentModalMd('Instructions')"
|
(click)="presentModalMd('Instructions')"
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
label="Instructions"
|
label="Instructions"
|
||||||
icon="@tui.chevron-right"
|
icon="@tui.chevron-right"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-item>
|
/>
|
||||||
<!-- versions -->
|
<!-- versions -->
|
||||||
<ng-content />
|
<ng-content />
|
||||||
<!-- links -->
|
<!-- links -->
|
||||||
@@ -51,28 +51,28 @@
|
|||||||
label="Marketing Site"
|
label="Marketing Site"
|
||||||
icon="@tui.external-link"
|
icon="@tui.external-link"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-link>
|
/>
|
||||||
<marketplace-additional-link
|
<marketplace-additional-link
|
||||||
*ngIf="pkg.upstreamRepo"
|
*ngIf="pkg.upstreamRepo"
|
||||||
[url]="pkg.upstreamRepo"
|
[url]="pkg.upstreamRepo"
|
||||||
label="Source Repository"
|
label="Source Repository"
|
||||||
icon="@tui.external-link"
|
icon="@tui.external-link"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-link>
|
/>
|
||||||
<marketplace-additional-link
|
<marketplace-additional-link
|
||||||
*ngIf="pkg.wrapperRepo"
|
*ngIf="pkg.wrapperRepo"
|
||||||
[url]="pkg.wrapperRepo"
|
[url]="pkg.wrapperRepo"
|
||||||
label="Wrapper Repository"
|
label="Wrapper Repository"
|
||||||
icon="@tui.external-link"
|
icon="@tui.external-link"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-link>
|
/>
|
||||||
<marketplace-additional-link
|
<marketplace-additional-link
|
||||||
*ngIf="pkg.supportSite"
|
*ngIf="pkg.supportSite"
|
||||||
[url]="pkg.supportSite"
|
[url]="pkg.supportSite"
|
||||||
label="Support Site"
|
label="Support Site"
|
||||||
icon="@tui.external-link"
|
icon="@tui.external-link"
|
||||||
class="item-pointer"
|
class="item-pointer"
|
||||||
></marketplace-additional-link>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
import { TuiDialogService } from '@taiga-ui/core'
|
import { TuiDialogService } from '@taiga-ui/core'
|
||||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
import { CopyService, Exver, MarkdownComponent } from '@start9labs/shared'
|
import { CopyService, MarkdownComponent } from '@start9labs/shared'
|
||||||
import { MarketplacePkg } from '../../../types'
|
import { MarketplacePkg } from '../../../types'
|
||||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||||
|
|
||||||
@@ -38,9 +38,8 @@ export class AdditionalComponent {
|
|||||||
size: 'l',
|
size: 'l',
|
||||||
data: {
|
data: {
|
||||||
content: this.marketplaceService.fetchStatic$(
|
content: this.marketplaceService.fetchStatic$(
|
||||||
this.pkg.id,
|
this.pkg,
|
||||||
label.toLowerCase(),
|
label === 'License' ? 'LICENSE.md' : 'instructions.md',
|
||||||
this.url,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
inject,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { MarketplaceDepItemComponent } from './dependency-item.component'
|
|
||||||
import { MarketplacePkg } from '../../../types'
|
import { MarketplacePkg } from '../../../types'
|
||||||
|
import { MarketplaceDepItemComponent } from './dependency-item.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'marketplace-dependencies',
|
selector: 'marketplace-dependencies',
|
||||||
@@ -17,7 +16,7 @@ import { MarketplacePkg } from '../../../types'
|
|||||||
<div class="dependencies-container">
|
<div class="dependencies-container">
|
||||||
<h2 class="additional-detail-title">Dependencies</h2>
|
<h2 class="additional-detail-title">Dependencies</h2>
|
||||||
<div class="dependencies-list">
|
<div class="dependencies-list">
|
||||||
@for (dep of pkg.manifest.dependencies | keyvalue; track $index) {
|
@for (dep of pkg.dependencyMetadata | keyvalue; track $index) {
|
||||||
<marketplace-dep-item
|
<marketplace-dep-item
|
||||||
[dep]="dep"
|
[dep]="dep"
|
||||||
[pkg]="pkg"
|
[pkg]="pkg"
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import { CommonModule, KeyValue } from '@angular/common'
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
Input,
|
|
||||||
inject,
|
inject,
|
||||||
|
Input,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
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 { TuiLet } from '@taiga-ui/cdk'
|
||||||
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
|
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
|
||||||
import { Dependency, MarketplacePkg, StoreIdentity } from '../../../types'
|
|
||||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||||
|
import { MarketplacePkg, StoreIdentity } from '../../../types'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'marketplace-dep-item',
|
selector: 'marketplace-dep-item',
|
||||||
@@ -104,7 +105,7 @@ import { AbstractMarketplaceService } from '../../../services/marketplace.servic
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
TuiAvatar,
|
TuiAvatar,
|
||||||
EmverPipesModule,
|
ExverPipesModule,
|
||||||
TuiLineClamp,
|
TuiLineClamp,
|
||||||
TuiLet,
|
TuiLet,
|
||||||
],
|
],
|
||||||
@@ -114,7 +115,7 @@ export class MarketplaceDepItemComponent {
|
|||||||
pkg!: MarketplacePkg
|
pkg!: MarketplacePkg
|
||||||
|
|
||||||
@Input({ required: true })
|
@Input({ required: true })
|
||||||
dep!: KeyValue<string, Dependency>
|
dep!: KeyValue<string, T.DependencyMetadata>
|
||||||
|
|
||||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||||
readonly marketplace$ = this.marketplaceService.getSelectedHost$()
|
readonly marketplace$ = this.marketplaceService.getSelectedHost$()
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
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'
|
import { MarketplacePkg } from '../../../types'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
standalone: true,
|
||||||
selector: 'marketplace-flavors',
|
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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink, TuiCell, TuiTitle, SharedPipesModule],
|
||||||
})
|
})
|
||||||
export class FlavorsComponent {
|
export class FlavorsComponent {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
@@ -18,26 +18,26 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
|
|||||||
<!-- icon -->
|
<!-- icon -->
|
||||||
<img
|
<img
|
||||||
[src]="determineIcon(marketplace) | trustUrl"
|
[src]="determineIcon(marketplace) | trustUrl"
|
||||||
alt="{{ pkg.manifest.title }} Icon"
|
alt="{{ pkg.title }} Icon"
|
||||||
/>
|
/>
|
||||||
<!-- color background -->
|
<!-- color background -->
|
||||||
<div class="color-background">
|
<div class="color-background">
|
||||||
<img
|
<img
|
||||||
[src]="determineIcon(marketplace) | trustUrl"
|
[src]="determineIcon(marketplace) | trustUrl"
|
||||||
alt="{{ pkg.manifest.title }} background image"
|
alt="{{ pkg.title }} background image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- background darkening overlay -->
|
<!-- background darkening overlay -->
|
||||||
<div class="dark-overlay"></div>
|
<div class="dark-overlay"></div>
|
||||||
<div class="inner-container-title">
|
<div class="inner-container-title">
|
||||||
<h2 ticker>
|
<h2 ticker>
|
||||||
{{ pkg.manifest.title }}
|
{{ pkg.title }}
|
||||||
</h2>
|
</h2>
|
||||||
<h3>
|
<h3>
|
||||||
{{ pkg.manifest.version }}
|
{{ pkg.version }}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
{{ pkg.manifest.description.short }}
|
{{ pkg.description.short }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- control buttons -->
|
<!-- control buttons -->
|
||||||
@@ -175,7 +175,7 @@ export class MarketplacePackageHeroComponent {
|
|||||||
const iconUrl = new URL(this.pkg.icon)
|
const iconUrl = new URL(this.pkg.icon)
|
||||||
return iconUrl.href
|
return iconUrl.href
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
|
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,16 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'marketplace-package-screenshots',
|
selector: 'marketplace-package-screenshots',
|
||||||
template: `
|
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
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
appearance="flat"
|
appearance="flat"
|
||||||
icon="@tui.chevron-left"
|
iconStart="@tui.chevron-left"
|
||||||
title="Previous"
|
title="Previous"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="carousel.prev()"
|
(click)="carousel.prev()"
|
||||||
@@ -29,7 +34,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
|||||||
[(index)]="index"
|
[(index)]="index"
|
||||||
class="carousel"
|
class="carousel"
|
||||||
>
|
>
|
||||||
<ng-container *ngFor="let item of pkg.screenshots; let i = index">
|
<ng-container *ngFor="let item of screenshots; let i = index">
|
||||||
<div
|
<div
|
||||||
*tuiItem
|
*tuiItem
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@@ -57,7 +62,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
|||||||
tuiIconButton
|
tuiIconButton
|
||||||
appearance="flat"
|
appearance="flat"
|
||||||
type="button"
|
type="button"
|
||||||
icon="@tui.chevron-right"
|
iconStart="@tui.chevron-right"
|
||||||
title="Next"
|
title="Next"
|
||||||
(click)="carousel.next()"
|
(click)="carousel.next()"
|
||||||
></button>
|
></button>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export * from './pages/show/dependencies/dependency-item.component'
|
|||||||
export * from './pages/show/screenshots/screenshots.component'
|
export * from './pages/show/screenshots/screenshots.component'
|
||||||
export * from './pages/show/hero/hero.component'
|
export * from './pages/show/hero/hero.component'
|
||||||
export * from './pages/show/flavors/flavors.component'
|
export * from './pages/show/flavors/flavors.component'
|
||||||
export * from './pages/show/flavors/flavors.module'
|
|
||||||
|
|
||||||
export * from './pipes/filter-packages.pipe'
|
export * from './pipes/filter-packages.pipe'
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import {
|
import { Marketplace, MarketplacePkg, StoreData, StoreIdentity } from '../types'
|
||||||
Marketplace,
|
|
||||||
MarketplacePkg,
|
|
||||||
StoreData,
|
|
||||||
StoreIdentity,
|
|
||||||
StoreIdentityWithData,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
export abstract class AbstractMarketplaceService {
|
export abstract class AbstractMarketplaceService {
|
||||||
abstract getKnownHosts$(): Observable<StoreIdentity[]>
|
abstract getKnownHosts$(): Observable<StoreIdentity[]>
|
||||||
@@ -16,7 +10,9 @@ export abstract class AbstractMarketplaceService {
|
|||||||
|
|
||||||
abstract getSelectedStore$(): Observable<StoreData>
|
abstract getSelectedStore$(): Observable<StoreData>
|
||||||
|
|
||||||
abstract getSelectedStoreWithCategories$(): Observable<StoreIdentityWithData>
|
abstract getSelectedStoreWithCategories$(): Observable<
|
||||||
|
StoreIdentity & StoreData
|
||||||
|
>
|
||||||
|
|
||||||
abstract getPackage$(
|
abstract getPackage$(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { ServerComponent } from '@start9labs/shared'
|
||||||
import { TuiDialogContext } from '@taiga-ui/core'
|
import { TuiDialogContext } from '@taiga-ui/core'
|
||||||
import {
|
import {
|
||||||
POLYMORPHEUS_CONTEXT,
|
POLYMORPHEUS_CONTEXT,
|
||||||
PolymorpheusComponent,
|
PolymorpheusComponent,
|
||||||
} from '@taiga-ui/polymorpheus'
|
} 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'
|
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
@@ -23,7 +24,7 @@ export interface ServersResponse {
|
|||||||
<button [server]="server" (password)="select($event, server.id)"></button>
|
<button [server]="server" (password)="select($event, server.id)"></button>
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
imports: [ServerComponent],
|
imports: [ServerComponent, PasswordDirective],
|
||||||
})
|
})
|
||||||
export class ServersComponent {
|
export class ServersComponent {
|
||||||
readonly context =
|
readonly context =
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
import { Router } from '@angular/router'
|
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 { T } from '@start9labs/start-sdk'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
@@ -40,9 +44,8 @@ export default class LoadingPage {
|
|||||||
startWith(progress),
|
startWith(progress),
|
||||||
catchError((_, watch$) =>
|
catchError((_, watch$) =>
|
||||||
interval(2000).pipe(
|
interval(2000).pipe(
|
||||||
switchMap(() =>
|
switchMap(() => from(this.api.getStatus())),
|
||||||
from(this.api.getStatus()).pipe(catchError(() => EMPTY)),
|
catchError(() => EMPTY),
|
||||||
),
|
|
||||||
take(1),
|
take(1),
|
||||||
switchMap(() => watch$),
|
switchMap(() => watch$),
|
||||||
),
|
),
|
||||||
@@ -54,13 +57,7 @@ export default class LoadingPage {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
map(({ phases, overall }) => ({
|
map(formatProgress),
|
||||||
total: getDecimal(overall),
|
|
||||||
message: phases
|
|
||||||
.filter(p => p.progress !== true && p.progress !== null)
|
|
||||||
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
|
|
||||||
.join(','),
|
|
||||||
})),
|
|
||||||
catchError(e => {
|
catchError(e => {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
return EMPTY
|
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})`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { ErrorService } from '@start9labs/shared'
|
import { ErrorService, ServerComponent } from '@start9labs/shared'
|
||||||
import {
|
import {
|
||||||
TuiButton,
|
TuiButton,
|
||||||
TuiDialogService,
|
TuiDialogService,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@taiga-ui/core'
|
} from '@taiga-ui/core'
|
||||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||||
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
|
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 { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { StateService } from 'src/app/services/state.service'
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ import { StateService } from 'src/app/services/state.service'
|
|||||||
TuiTitle,
|
TuiTitle,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
ServerComponent,
|
ServerComponent,
|
||||||
|
PasswordDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class RecoverPage {
|
export default class RecoverPage {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@
|
|||||||
"@angular/router": "^17.0.6",
|
"@angular/router": "^17.0.6",
|
||||||
"@ng-web-apis/mutation-observer": ">=4.0.0",
|
"@ng-web-apis/mutation-observer": ">=4.0.0",
|
||||||
"@ng-web-apis/resize-observer": ">=4.0.0",
|
"@ng-web-apis/resize-observer": ">=4.0.0",
|
||||||
"@start9labs/emver": "^0.1.5",
|
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||||
"@taiga-ui/cdk": "4.0.0-rc.6",
|
"@taiga-ui/core": "4.0.0-rc.7",
|
||||||
"@taiga-ui/core": "4.0.0-rc.6",
|
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||||
"@taiga-ui/experimental": "4.0.0-rc.6",
|
|
||||||
"@tinkoff/ng-dompurify": ">=4.0.0",
|
"@tinkoff/ng-dompurify": ">=4.0.0",
|
||||||
"ansi-to-html": "^0.7.2"
|
"ansi-to-html": "^0.7.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { Component, ElementRef, inject, input, Output } from '@angular/core'
|
import { Component, inject, input } from '@angular/core'
|
||||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
|
||||||
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||||
import { TuiCell } from '@taiga-ui/layout'
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
import { filter, fromEvent, switchMap } from 'rxjs'
|
import { StartOSDiskInfo } from '../types/api'
|
||||||
import { PASSWORD } from 'src/app/components/password.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -31,16 +29,4 @@ export class ServerComponent {
|
|||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
|
|
||||||
readonly server = input.required<StartOSDiskInfo>()
|
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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ export * from './components/markdown/markdown.component.module'
|
|||||||
export * from './components/ticker/ticker.component'
|
export * from './components/ticker/ticker.component'
|
||||||
export * from './components/ticker/ticker.module'
|
export * from './components/ticker/ticker.module'
|
||||||
export * from './components/drive.component'
|
export * from './components/drive.component'
|
||||||
|
export * from './components/server.component'
|
||||||
|
|
||||||
export * from './directives/drag-scroller.directive'
|
export * from './directives/drag-scroller.directive'
|
||||||
export * from './directives/safe-links.directive'
|
export * from './directives/safe-links.directive'
|
||||||
@@ -50,6 +51,7 @@ export * from './tokens/theme'
|
|||||||
export * from './util/base-64'
|
export * from './util/base-64'
|
||||||
export * from './util/convert-ansi'
|
export * from './util/convert-ansi'
|
||||||
export * from './util/copy-to-clipboard'
|
export * from './util/copy-to-clipboard'
|
||||||
|
export * from './util/format-progress'
|
||||||
export * from './util/get-new-entries'
|
export * from './util/get-new-entries'
|
||||||
export * from './util/get-pkg-id'
|
export * from './util/get-pkg-id'
|
||||||
export * from './util/invert'
|
export * from './util/invert'
|
||||||
|
|||||||
33
web/projects/shared/src/util/format-progress.ts
Normal file
33
web/projects/shared/src/util/format-progress.ts
Normal 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})`
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import { PatchMonitorService } from './services/patch-monitor.service'
|
|||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
private readonly title = inject(Title)
|
private readonly title = inject(Title)
|
||||||
private readonly patch = inject(PatchDB<DataModel>)
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
|
||||||
readonly auth = inject(AuthService)
|
readonly auth = inject(AuthService)
|
||||||
readonly theme$ = inject(THEME)
|
readonly theme$ = inject(THEME)
|
||||||
@@ -29,7 +29,7 @@ export class AppComponent implements OnInit {
|
|||||||
.subscribe()
|
.subscribe()
|
||||||
|
|
||||||
readonly offline$ = combineLatest([
|
readonly offline$ = combineLatest([
|
||||||
inject(ConnectionService).connected$,
|
inject(ConnectionService),
|
||||||
this.auth.isVerified$,
|
this.auth.isVerified$,
|
||||||
this.patch
|
this.patch
|
||||||
.watch$('serverInfo', 'statusInfo')
|
.watch$('serverInfo', 'statusInfo')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||||
import {
|
import {
|
||||||
TUI_DATE_FORMAT,
|
TUI_DATE_FORMAT,
|
||||||
|
TUI_DIALOGS_CLOSE,
|
||||||
tuiButtonOptionsProvider,
|
tuiButtonOptionsProvider,
|
||||||
tuiDropdownOptionsProvider,
|
tuiDropdownOptionsProvider,
|
||||||
tuiNumberFormatProvider,
|
tuiNumberFormatProvider,
|
||||||
@@ -19,7 +20,13 @@ import {
|
|||||||
TUI_DATE_VALUE_TRANSFORMER,
|
TUI_DATE_VALUE_TRANSFORMER,
|
||||||
} from '@taiga-ui/kit'
|
} from '@taiga-ui/kit'
|
||||||
import { tuiTextfieldOptionsProvider } from '@taiga-ui/legacy'
|
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 { ApiService } from './services/api/embassy-api.service'
|
||||||
import { LiveApiService } from './services/api/embassy-live-api.service'
|
import { LiveApiService } from './services/api/embassy-live-api.service'
|
||||||
import { MockApiService } from './services/api/embassy-mock-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 { DateTransformerService } from './services/date-transformer.service'
|
||||||
import { DatetimeTransformerService } from './services/datetime-transformer.service'
|
import { DatetimeTransformerService } from './services/datetime-transformer.service'
|
||||||
import { MarketplaceService } from './services/marketplace.service'
|
import { MarketplaceService } from './services/marketplace.service'
|
||||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
|
||||||
import { StorageService } from './services/storage.service'
|
import { StorageService } from './services/storage.service'
|
||||||
|
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
useMocks,
|
useMocks,
|
||||||
@@ -38,7 +45,6 @@ const {
|
|||||||
} = require('../../../../config.json') as WorkspaceConfig
|
} = require('../../../../config.json') as WorkspaceConfig
|
||||||
|
|
||||||
export const APP_PROVIDERS: Provider[] = [
|
export const APP_PROVIDERS: Provider[] = [
|
||||||
PATCH_DB_PROVIDERS,
|
|
||||||
NG_EVENT_PLUGINS,
|
NG_EVENT_PLUGINS,
|
||||||
FilterPackagesPipe,
|
FilterPackagesPipe,
|
||||||
UntypedFormBuilder,
|
UntypedFormBuilder,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
|
|
||||||
:first-child {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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]) || []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
.button {
|
|
||||||
pointer-events: auto;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masked {
|
|
||||||
-webkit-text-security: disc;
|
|
||||||
}
|
|
||||||
@@ -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 || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
|
|
||||||
> {}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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> {}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
display: block;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export class NotificationsToastComponent {
|
|||||||
|
|
||||||
readonly visible$: Observable<boolean> = merge(
|
readonly visible$: Observable<boolean> = merge(
|
||||||
this.dismiss$,
|
this.dismiss$,
|
||||||
inject(PatchDB<DataModel>)
|
inject<PatchDB<DataModel>>(PatchDB)
|
||||||
.watch$('serverInfo', 'unreadNotifications', 'count')
|
.watch$('serverInfo', 'unreadNotifications', 'count')
|
||||||
.pipe(
|
.pipe(
|
||||||
pairwise(),
|
pairwise(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
import { SwUpdate } from '@angular/service-worker'
|
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 { TuiAutoFocus } from '@taiga-ui/cdk'
|
||||||
import { TuiButton, TuiDialog } from '@taiga-ui/core'
|
import { TuiButton, TuiDialog } from '@taiga-ui/core'
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
@@ -47,16 +47,16 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
|||||||
export class RefreshAlertComponent {
|
export class RefreshAlertComponent {
|
||||||
private readonly updates = inject(SwUpdate)
|
private readonly updates = inject(SwUpdate)
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
private readonly emver = inject(Emver)
|
private readonly exver = inject(Exver)
|
||||||
private readonly config = inject(ConfigService)
|
private readonly config = inject(ConfigService)
|
||||||
private readonly dismiss$ = new Subject<boolean>()
|
private readonly dismiss$ = new Subject<boolean>()
|
||||||
|
|
||||||
readonly show$ = merge(
|
readonly show$ = merge(
|
||||||
this.dismiss$,
|
this.dismiss$,
|
||||||
inject(PatchDB<DataModel>)
|
inject<PatchDB<DataModel>>(PatchDB)
|
||||||
.watch$('serverInfo', 'version')
|
.watch$('serverInfo', 'version')
|
||||||
.pipe(
|
.pipe(
|
||||||
map(version => !!this.emver.compare(this.config.version, version)),
|
map(version => !!this.exver.compareExver(this.config.version, version)),
|
||||||
endWith(false),
|
endWith(false),
|
||||||
),
|
),
|
||||||
).pipe(debounceTime(0))
|
).pipe(debounceTime(0))
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class UpdateToastComponent {
|
|||||||
|
|
||||||
readonly visible$: Observable<boolean> = merge(
|
readonly visible$: Observable<boolean> = merge(
|
||||||
this.dismiss$,
|
this.dismiss$,
|
||||||
inject(PatchDB<DataModel>)
|
inject<PatchDB<DataModel>>(PatchDB)
|
||||||
.watch$('serverInfo', 'statusInfo', 'updated')
|
.watch$('serverInfo', 'statusInfo', 'updated')
|
||||||
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false)),
|
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(' → ')
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,9 +25,9 @@ import { StoreIconComponent } from './store-icon.component'
|
|||||||
</div>
|
</div>
|
||||||
<tui-icon
|
<tui-icon
|
||||||
*ngIf="registry.selected; else content"
|
*ngIf="registry.selected; else content"
|
||||||
icon="tuiIconCheck"
|
icon="@tui.check"
|
||||||
[style.color]="'var(--tui-positive)'"
|
[style.color]="'var(--tui-positive)'"
|
||||||
></tui-icon>
|
/>
|
||||||
<ng-template #content><ng-content></ng-content></ng-template>
|
<ng-template #content><ng-content></ng-content></ng-template>
|
||||||
`,
|
`,
|
||||||
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
|
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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})`
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
@@ -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> ${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
Reference in New Issue
Block a user