mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-04 14:29:45 +00:00
refactor: completely remove ionic
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { WorkspaceConfig } from '@start9labs/shared'
|
||||
import { DiagnosticService } from './services/diagnostic.service'
|
||||
import { MockDiagnosticService } from './services/mock-diagnostic.service'
|
||||
import { LiveDiagnosticService } from './services/live-diagnostic.service'
|
||||
|
||||
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
|
||||
|
||||
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)],
|
||||
providers: [
|
||||
{
|
||||
provide: DiagnosticService,
|
||||
useClass: useMocks ? MockDiagnosticService : LiveDiagnosticService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class DiagnosticModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TuiButtonModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
<ng-container *ngIf="!restarted; else refresh">
|
||||
<h1 class="title">StartOS - Diagnostic Mode</h1>
|
||||
|
||||
<ng-container *ngIf="error">
|
||||
<h2 class="subtitle">StartOS launch error:</h2>
|
||||
<code class="code warning">
|
||||
<p>{{ error.problem }}</p>
|
||||
<p *ngIf="error.details">{{ error.details }}</p>
|
||||
</code>
|
||||
|
||||
<a tuiButton routerLink="logs">View Logs</a>
|
||||
|
||||
<h2 class="subtitle">Possible solutions:</h2>
|
||||
<code class="code"><p>{{ error.solution }}</p></code>
|
||||
|
||||
<div class="buttons">
|
||||
<button tuiButton (click)="restart()">Restart Server</button>
|
||||
|
||||
<button
|
||||
*ngIf="error.code === 15 || error.code === 25"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="forgetDrive()"
|
||||
>
|
||||
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-warning"
|
||||
(click)="presentAlertSystemRebuild()"
|
||||
>
|
||||
System Rebuild
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
(click)="presentAlertRepairDisk()"
|
||||
>
|
||||
Repair Drive
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
<h1 class="title">Server is restarting</h1>
|
||||
<h2 class="subtitle">
|
||||
Wait for the server to restart, then refresh this page.
|
||||
</h2>
|
||||
<button tuiButton (click)="refreshPage()">Refresh</button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,37 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 2rem;
|
||||
overflow: auto;
|
||||
background: var(--tui-base-01);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
padding-bottom: 1.5rem;
|
||||
font-size: calc(2vw + 1rem);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-size: calc(1vw + 0.75rem);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: block;
|
||||
color: var(--tui-success-fill);
|
||||
background: rgb(69, 69, 69);
|
||||
padding: 1px 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
194
web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
Normal file
194
web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
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 { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-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,
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
@Inject(WINDOW) private readonly window: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const error = await this.api.getError()
|
||||
// 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> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.forgetDrive()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertSystemRebuild() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
try {
|
||||
await this.api.repairDisk()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IntersectionObserverModule } from '@ng-web-apis/intersection-observer'
|
||||
import { MutationObserverModule } from '@ng-web-apis/mutation-observer'
|
||||
import { TuiLoaderModule, TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
import { LogsPage } from './logs.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule.forChild(ROUTES),
|
||||
IntersectionObserverModule,
|
||||
MutationObserverModule,
|
||||
NgDompurifyModule,
|
||||
TuiBadgeModule,
|
||||
TuiButtonModule,
|
||||
TuiLoaderModule,
|
||||
TuiScrollbarModule,
|
||||
],
|
||||
declarations: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
<a
|
||||
routerLink="../"
|
||||
tuiButton
|
||||
iconLeft="tuiIconChevronLeft"
|
||||
appearance="icon"
|
||||
[style.align-self]="'flex-start'"
|
||||
>
|
||||
Back
|
||||
</a>
|
||||
<tui-scrollbar childList subtree (waMutationObserver)="restoreScroll()">
|
||||
<section
|
||||
class="top"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="onTop($event[0].isIntersecting)"
|
||||
>
|
||||
@if (loading) {
|
||||
<tui-loader textContent="Loading logs" />
|
||||
}
|
||||
</section>
|
||||
@for (log of logs; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
</tui-scrollbar>
|
||||
79
web/projects/ui/src/app/routes/diagnostic/logs/logs.page.ts
Normal file
79
web/projects/ui/src/app/routes/diagnostic/logs/logs.page.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
||||
import { INTERSECTION_ROOT } from '@ng-web-apis/intersection-observer'
|
||||
import { convertAnsi, ErrorService } from '@start9labs/shared'
|
||||
import { TuiScrollbarComponent } from '@taiga-ui/core'
|
||||
import { DiagnosticService } from 'src/app/routes/diagnostic/services/diagnostic.service'
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
styles: `
|
||||
:host {
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
background: var(--tui-base-01);
|
||||
}
|
||||
`,
|
||||
providers: [
|
||||
{
|
||||
provide: INTERSECTION_ROOT,
|
||||
useExisting: ElementRef,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class LogsPage implements OnInit {
|
||||
@ViewChild(TuiScrollbarComponent, { read: ElementRef })
|
||||
private readonly scrollbar?: ElementRef<HTMLElement>
|
||||
private readonly api = inject(DiagnosticService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
startCursor?: string
|
||||
loading = false
|
||||
logs: string[] = []
|
||||
scrollTop = 0
|
||||
|
||||
ngOnInit() {
|
||||
this.getLogs()
|
||||
}
|
||||
|
||||
onTop(top: boolean) {
|
||||
if (top) this.getLogs()
|
||||
}
|
||||
|
||||
restoreScroll() {
|
||||
if (this.loading || !this.scrollbar) return
|
||||
|
||||
const scrollbar = this.scrollbar.nativeElement
|
||||
const offset = scrollbar.querySelector('pre')?.clientHeight || 0
|
||||
|
||||
scrollbar.scrollTop = this.scrollTop + offset
|
||||
}
|
||||
|
||||
private async getLogs() {
|
||||
if (this.loading) return
|
||||
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const response = await this.api.getLogs({
|
||||
cursor: this.startCursor,
|
||||
before: !!this.startCursor,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
if (!response.entries.length) return
|
||||
|
||||
this.startCursor = response.startCursor
|
||||
this.logs = [convertAnsi(response.entries), ...this.logs]
|
||||
this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
|
||||
|
||||
export abstract class DiagnosticService {
|
||||
abstract getError(): Promise<GetErrorRes>
|
||||
abstract restart(): Promise<void>
|
||||
abstract forgetDrive(): Promise<void>
|
||||
abstract repairDisk(): Promise<void>
|
||||
abstract systemRebuild(): Promise<void>
|
||||
abstract getLogs(params: FetchLogsReq): Promise<FetchLogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
code: number
|
||||
message: string
|
||||
data: { details: string }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class LiveDiagnosticService implements DiagnosticService {
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
return this.rpcRequest<GetErrorRes>({
|
||||
method: 'diagnostic.error',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.forget',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.repair',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.rebuild',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
|
||||
return this.rpcRequest<FetchLogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { FetchLogsReq, FetchLogsRes, Log } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class MockDiagnosticService implements DiagnosticService {
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown server',
|
||||
data: { details: 'Some details about the error here' },
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async getLogs(params: FetchLogsReq): Promise<FetchLogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
if (Math.random() < 0.2) {
|
||||
entries = packageLogs
|
||||
} else {
|
||||
const arrLength = params.limit
|
||||
? Math.ceil(params.limit / packageLogs.length)
|
||||
: 10
|
||||
entries = new Array(arrLength)
|
||||
.fill(packageLogs)
|
||||
.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
startCursor: 'start-cursor',
|
||||
endCursor: 'end-cursor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageLogs = [
|
||||
{
|
||||
timestamp: '2019-12-26T14:20:30.872Z',
|
||||
message: '****** START *****',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
},
|
||||
]
|
||||
25
web/projects/ui/src/app/routes/loading/loading.page.ts
Normal file
25
web/projects/ui/src/app/routes/loading/loading.page.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
InitializingComponent,
|
||||
provideSetupLogsService,
|
||||
provideSetupService,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<app-initializing (finished)="router.navigate(['login'])" />
|
||||
`,
|
||||
providers: [
|
||||
provideSetupService(ApiService),
|
||||
provideSetupLogsService(ApiService),
|
||||
],
|
||||
styles: ':host { padding: 1rem; }',
|
||||
imports: [InitializingComponent],
|
||||
})
|
||||
export default class LoadingPage {
|
||||
readonly router = inject(Router)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<div
|
||||
*ngIf="!caTrusted; else trusted"
|
||||
tuiCardLarge
|
||||
tuiSurface="elevated"
|
||||
class="card"
|
||||
>
|
||||
<tui-icon icon="tuiIconLock" [style.font-size.rem]="4" />
|
||||
<h1>Trust Your Root CA</h1>
|
||||
<p>
|
||||
Download and trust your server's Root Certificate Authority to establish a
|
||||
secure (HTTPS) connection. You will need to repeat this on every device you
|
||||
use to connect to your server.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Bookmark this page</b>
|
||||
- Save this page so you can access it later. You can also find the address
|
||||
in the
|
||||
<code>StartOS-info.html</code>
|
||||
file downloaded at the end of initial setup.
|
||||
</li>
|
||||
<li>
|
||||
<b>Download your server's Root CA</b>
|
||||
- Your server uses its Root CA to generate SSL/TLS certificates for itself
|
||||
and installed services. These certificates are then used to encrypt
|
||||
network traffic with your client devices.
|
||||
<br />
|
||||
<a
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="tertiary-solid"
|
||||
iconRight="tuiIconDownload"
|
||||
href="/eos/local.crt"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<b>Trust your server's Root CA</b>
|
||||
- Follow instructions for your OS. By trusting your server's Root CA, your
|
||||
device can verify the authenticity of encrypted communications with your
|
||||
server.
|
||||
<br />
|
||||
<a
|
||||
tuiButton
|
||||
size="s"
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
iconRight="tuiIconExternalLink"
|
||||
>
|
||||
View Instructions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<b>Test</b>
|
||||
- Refresh the page. If refreshing the page does not work, you may need to
|
||||
quit and re-open your browser, then revisit this page.
|
||||
<br />
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
class="refresh"
|
||||
appearance="success-solid"
|
||||
iconRight="tuiIconRefreshCw"
|
||||
(click)="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="flat"
|
||||
iconRight="tuiIconExternalLink"
|
||||
(click)="launchHttps()"
|
||||
[disabled]="caTrusted"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<div><small>(not recommended)</small></div>
|
||||
</div>
|
||||
|
||||
<ng-template #trusted>
|
||||
<div tuiCardLarge tuiSurface="elevated" class="card">
|
||||
<tui-icon
|
||||
icon="tuiIconShield"
|
||||
tuiAppearance="icon-success"
|
||||
[style.font-size.rem]="4"
|
||||
/>
|
||||
<h1>Root CA Trusted!</h1>
|
||||
<p>
|
||||
You have successfully trusted your server's Root CA and may now log in
|
||||
securely.
|
||||
</p>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="tertiary-solid"
|
||||
iconRight="tuiIconExternalLink"
|
||||
(click)="launchHttps()"
|
||||
>
|
||||
Go to login
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,44 @@
|
||||
:host {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
[tuiButton] {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
border-radius: 10rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: max(70%, 40rem);
|
||||
text-align: center;
|
||||
align-items: center !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.5rem;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CommonModule, DOCUMENT } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { RELATIVE_URL } from '@start9labs/shared'
|
||||
import {
|
||||
TuiAppearanceModule,
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiIconModule,
|
||||
TuiSurfaceModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'ca-wizard',
|
||||
templateUrl: './ca-wizard.component.html',
|
||||
styleUrls: ['./ca-wizard.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiIconModule,
|
||||
TuiButtonModule,
|
||||
TuiAppearanceModule,
|
||||
TuiCardModule,
|
||||
TuiSurfaceModule,
|
||||
],
|
||||
})
|
||||
export class CAWizardComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly relativeUrl = inject(RELATIVE_URL)
|
||||
private readonly document = inject(DOCUMENT)
|
||||
|
||||
readonly config = inject(ConfigService)
|
||||
caTrusted = false
|
||||
|
||||
async ngOnInit() {
|
||||
await this.testHttps().catch(e =>
|
||||
console.warn('Failed Https connection attempt'),
|
||||
)
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.document.location.reload()
|
||||
}
|
||||
|
||||
launchHttps() {
|
||||
this.document.defaultView?.open(`https://${this.config.getHost()}`, '_self')
|
||||
}
|
||||
|
||||
private async testHttps() {
|
||||
const url = `https://${this.document.location.host}${this.relativeUrl}`
|
||||
await this.api.echo({ message: 'ping' }, url).then(() => {
|
||||
this.caTrusted = true
|
||||
})
|
||||
}
|
||||
}
|
||||
37
web/projects/ui/src/app/routes/login/login.module.ts
Normal file
37
web/projects/ui/src/app/routes/login/login.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiErrorModule, TuiTextfieldControllerModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiSurfaceModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { TuiInputPasswordModule } from '@taiga-ui/kit'
|
||||
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
|
||||
import { LoginPage } from './login.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoginPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
CAWizardComponent,
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiSurfaceModule,
|
||||
TuiInputPasswordModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiErrorModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [LoginPage],
|
||||
})
|
||||
export class LoginPageModule {}
|
||||
24
web/projects/ui/src/app/routes/login/login.page.html
Normal file
24
web/projects/ui/src/app/routes/login/login.page.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- Local HTTP -->
|
||||
<ca-wizard *ngIf="config.isLanHttp(); else notLanHttp"></ca-wizard>
|
||||
|
||||
<!-- not Local HTTP -->
|
||||
<ng-template #notLanHttp>
|
||||
<div tuiCardLarge tuiSurface="elevated" class="card">
|
||||
<img alt="StartOS Icon" class="logo" src="assets/img/icon.png" />
|
||||
<h1 class="header">Login to StartOS</h1>
|
||||
<form (submit)="submit()">
|
||||
<tui-input-password
|
||||
tuiTextfieldIconLeft="tuiIconKeyLarge"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="password"
|
||||
(ngModelChange)="error = ''"
|
||||
>
|
||||
Password
|
||||
</tui-input-password>
|
||||
<tui-error class="error" [error]="error || null" />
|
||||
<button tuiButton class="button" appearance="tertiary-solid">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</ng-template>
|
||||
35
web/projects/ui/src/app/routes/login/login.page.scss
Normal file
35
web/projects/ui/src/app/routes/login/login.page.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
background: var(--tui-base-02);
|
||||
}
|
||||
|
||||
.card {
|
||||
@include center-all();
|
||||
overflow: visible;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: max(33%, 20rem);
|
||||
}
|
||||
|
||||
.logo {
|
||||
@include center-left();
|
||||
top: -17%;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 2rem 0 1rem;
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 10rem;
|
||||
border-radius: 10rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
61
web/projects/ui/src/app/routes/login/login.page.ts
Normal file
61
web/projects/ui/src/app/routes/login/login.page.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'login',
|
||||
templateUrl: './login.page.html',
|
||||
styleUrls: ['./login.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class LoginPage {
|
||||
password = ''
|
||||
error = ''
|
||||
|
||||
constructor(
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly router: Router,
|
||||
private readonly authService: AuthService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
) {}
|
||||
|
||||
async submit() {
|
||||
this.error = ''
|
||||
|
||||
const loader = this.loader
|
||||
.open('Logging in...')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
this.document.cookie = ''
|
||||
if (this.password.length > 64) {
|
||||
this.error = 'Password must be less than 65 characters'
|
||||
return
|
||||
}
|
||||
await this.api.login({
|
||||
password: this.password,
|
||||
// TODO: get platforms metadata
|
||||
metadata: { platforms: [] },
|
||||
})
|
||||
|
||||
this.password = ''
|
||||
this.authService.setVerified()
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
} catch (e: any) {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid Password' : e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiDataListModule } from '@taiga-ui/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
|
||||
export interface Action {
|
||||
icon: string
|
||||
label: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
template: `
|
||||
<tui-data-list>
|
||||
<h3 class="title"><ng-content /></h3>
|
||||
<tui-opt-group
|
||||
*ngFor="let group of actions | keyvalue: asIsOrder"
|
||||
[label]="group.key.toUpperCase()"
|
||||
>
|
||||
<button
|
||||
*ngFor="let action of group.value"
|
||||
tuiOption
|
||||
class="item"
|
||||
(click)="action.action()"
|
||||
>
|
||||
<tui-icon class="icon" [icon]="action.icon" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.title {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.item {
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiDataListModule, CommonModule, TuiIconModule],
|
||||
})
|
||||
export class ActionsComponent {
|
||||
@Input()
|
||||
actions: Record<string, readonly Action[]> = {}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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/routes/portal/components/form/form.module'
|
||||
import { InvalidService } from 'src/app/routes/portal/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" />
|
||||
<footer tuiMode="onDark">
|
||||
<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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<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>
|
||||
@@ -0,0 +1,50 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, HostBinding, inject, Input } from '@angular/core'
|
||||
import { AbstractControl, FormArrayName } from '@angular/forms'
|
||||
import { TUI_PARENT_STOP, 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_STOP],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class FormArrayComponent {
|
||||
@Input({ required: true })
|
||||
spec!: CT.ValueSpecList
|
||||
|
||||
@HostBinding('@tuiParentStop')
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<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-svg
|
||||
src="tuiIconPaintLarge"
|
||||
tuiWrapper
|
||||
appearance="icon"
|
||||
class="icon"
|
||||
></tui-svg>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,33 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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)],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<ng-container [ngSwitch]="spec.type">
|
||||
<form-color *ngSwitchCase="'color'"></form-color>
|
||||
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
|
||||
<form-file *ngSwitchCase="'file'"></form-file>
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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({ required: true })
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -0,0 +1,36 @@
|
||||
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)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input-files
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
<input tuiInputFiles [accept]="spec.extensions.join(',')" />
|
||||
<ng-template let-drop>
|
||||
<div class="template" [class.template_hidden]="drop">
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
></tui-tooltip>
|
||||
</div>
|
||||
<tui-tag
|
||||
*ngIf="value; else label"
|
||||
class="file"
|
||||
size="l"
|
||||
[value]="value.name"
|
||||
[removable]="true"
|
||||
(edited)="value = null"
|
||||
></tui-tag>
|
||||
<ng-template #label>
|
||||
<small>Click or drop file here</small>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="drop" [class.drop_hidden]="!drop">Drop file here</div>
|
||||
</ng-template>
|
||||
</tui-input-files>
|
||||
@@ -0,0 +1,46 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.template {
|
||||
@include transition(opacity);
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
|
||||
&_hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drop {
|
||||
@include fullsize();
|
||||
@include transition(opacity);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
&_hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
small {
|
||||
max-width: 50%;
|
||||
font-weight: normal;
|
||||
color: var(--tui-text-02);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
tui-tag {
|
||||
z-index: 1;
|
||||
margin: -0.25rem -0.25rem -0.25rem auto;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { TuiFileLike } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-file',
|
||||
templateUrl: './form-file.component.html',
|
||||
styleUrls: ['./form-file.component.scss'],
|
||||
})
|
||||
export class FormFileComponent extends Control<CT.ValueSpecFile, TuiFileLike> {}
|
||||
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
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]) || []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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 ? 'secondary-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>
|
||||
@@ -0,0 +1,41 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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({ required: true })
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<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
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-select>
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<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>
|
||||
@@ -0,0 +1,8 @@
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { getDefaultString } from 'src/app/utils/config-utilities'
|
||||
|
||||
@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 = getDefaultString(this.spec.generate || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<tui-text-area
|
||||
[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-text-area>
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ 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>
|
||||
@@ -0,0 +1,10 @@
|
||||
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> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
<form-control
|
||||
[spec]="selectSpec"
|
||||
[formControlName]="select"
|
||||
(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>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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({ required: true })
|
||||
spec!: CT.ValueSpecUnion
|
||||
|
||||
selectSpec!: CT.ValueSpecSelect
|
||||
|
||||
readonly select = CT.unionSelectKey
|
||||
readonly value = CT.unionValueKey
|
||||
|
||||
private readonly form = inject(FormGroupName)
|
||||
private readonly formService = inject(FormService)
|
||||
|
||||
get union(): string {
|
||||
return this.form.value[CT.unionSelectKey]
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
onUnion(union: string) {
|
||||
this.form.control.setControl(
|
||||
CT.unionValueKey,
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
TuiSvgModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiTooltipModule,
|
||||
TuiWrapperModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule } 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 { FormFileComponent } from './form-file/form-file.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,
|
||||
TuiSvgModule,
|
||||
TuiWrapperModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiMapperPipeModule,
|
||||
],
|
||||
declarations: [
|
||||
FormGroupComponent,
|
||||
FormControlComponent,
|
||||
FormColorComponent,
|
||||
FormDatetimeComponent,
|
||||
FormTextComponent,
|
||||
FormToggleComponent,
|
||||
FormTextareaComponent,
|
||||
FormNumberComponent,
|
||||
FormSelectComponent,
|
||||
FormMultiselectComponent,
|
||||
FormFileComponent,
|
||||
FormUnionComponent,
|
||||
FormObjectComponent,
|
||||
FormArrayComponent,
|
||||
MustachePipe,
|
||||
HintPipe,
|
||||
ControlDirective,
|
||||
],
|
||||
exports: [FormGroupComponent],
|
||||
})
|
||||
export class FormModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { CopyService, EmverPipesModule } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *ngIf="server$ | async as server">
|
||||
<div tuiCell>
|
||||
<div tuiTitle>
|
||||
<strong>Version</strong>
|
||||
<div tuiSubtitle>{{ server.version | displayEmver }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<div tuiTitle>
|
||||
<strong>Git Hash</strong>
|
||||
<div tuiSubtitle>{{ gitHash }}</div>
|
||||
</div>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconCopy"
|
||||
(click)="copyService.copy(gitHash)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<div tuiTitle>
|
||||
<strong>CA fingerprint</strong>
|
||||
<div tuiSubtitle>{{ server.caFingerprint }}</div>
|
||||
</div>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconCopy"
|
||||
(click)="copyService.copy(server.caFingerprint)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: ['[tuiCell] { padding-inline: 0 }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
EmverPipesModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
],
|
||||
})
|
||||
export class AboutComponent {
|
||||
readonly server$ = inject(PatchDB<DataModel>).watch$('serverInfo')
|
||||
readonly copyService = inject(CopyService)
|
||||
readonly gitHash = inject(ConfigService).gitHash
|
||||
}
|
||||
|
||||
export const ABOUT = new PolymorpheusComponent(AboutComponent)
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
|
||||
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TUI_ANIMATION_OPTIONS,
|
||||
tuiFadeIn,
|
||||
tuiWidthCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'a[headerBreadcrumb]',
|
||||
template: `
|
||||
@if (item.icon?.startsWith('tuiIcon')) {
|
||||
<tui-icon [icon]="item.icon || ''" />
|
||||
} @else if (item.icon) {
|
||||
<img [style.width.rem]="2" [src]="item.icon" [alt]="item.title" />
|
||||
}
|
||||
<span tuiTitle>
|
||||
{{ item.title }}
|
||||
@if (item.subtitle) {
|
||||
<span tuiSubtitle="">{{ item.subtitle }}</span>
|
||||
}
|
||||
</span>
|
||||
<ng-content />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 1.25rem;
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
--clip-path: polygon(
|
||||
calc(100% - 1.75rem) 0%,
|
||||
calc(100% - 0.875rem) 50%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
0.875rem 50%,
|
||||
0% 0%
|
||||
);
|
||||
|
||||
&:not(.active) {
|
||||
--clip-path: polygon(
|
||||
calc(100% - 1.75rem) 0%,
|
||||
calc(100% - 0.875rem) 50%,
|
||||
calc(100% - 1.75rem) 100%,
|
||||
0% 100%,
|
||||
0.875rem 50%,
|
||||
0% 0%
|
||||
);
|
||||
}
|
||||
|
||||
& > * {
|
||||
font-weight: bold;
|
||||
gap: 0;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
&::before {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIconModule, TuiTitleModule],
|
||||
animations: [tuiWidthCollapse, tuiFadeIn],
|
||||
})
|
||||
export class HeaderBreadcrumbComponent {
|
||||
@Input({ required: true, alias: 'headerBreadcrumb' })
|
||||
item!: Breadcrumb
|
||||
|
||||
@HostBinding('@tuiFadeIn')
|
||||
@HostBinding('@tuiWidthCollapse')
|
||||
readonly animation = inject(TUI_ANIMATION_OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, Observable, startWith } from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'header-connection',
|
||||
template: `
|
||||
<ng-content />
|
||||
@if (connection$ | async; as connection) {
|
||||
<!-- data-connection is used to display color indicator in the header through :has() -->
|
||||
<tui-icon
|
||||
[icon]="connection.icon"
|
||||
[style.color]="connection.color"
|
||||
[style.font-size.em]="1.5"
|
||||
[attr.data-connection]="connection.status"
|
||||
></tui-icon>
|
||||
{{ connection.message }}
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIconModule, AsyncPipe],
|
||||
})
|
||||
export class HeaderConnectionComponent {
|
||||
readonly connection$: Observable<{
|
||||
message: string
|
||||
color: string
|
||||
icon: string
|
||||
status: string
|
||||
}> = combineLatest([
|
||||
inject(ConnectionService).networkConnected$,
|
||||
inject(ConnectionService).websocketConnected$.pipe(startWith(false)),
|
||||
inject(PatchDB<DataModel>)
|
||||
.watch$('serverInfo', 'statusInfo')
|
||||
.pipe(startWith({ restarting: false, shuttingDown: false })),
|
||||
]).pipe(
|
||||
map(([network, websocket, status]) => {
|
||||
if (!network)
|
||||
return {
|
||||
message: 'No Internet',
|
||||
color: 'var(--tui-error-fill)',
|
||||
icon: 'tuiIconCloudOff',
|
||||
status: 'error',
|
||||
}
|
||||
if (!websocket)
|
||||
return {
|
||||
message: 'Connecting',
|
||||
color: 'var(--tui-warning-fill)',
|
||||
icon: 'tuiIconCloudOff',
|
||||
status: 'warning',
|
||||
}
|
||||
if (status.shuttingDown)
|
||||
return {
|
||||
message: 'Shutting Down',
|
||||
color: 'var(--tui-neutral-fill)',
|
||||
icon: 'tuiIconPower',
|
||||
status: 'neutral',
|
||||
}
|
||||
if (status.restarting)
|
||||
return {
|
||||
message: 'Restarting',
|
||||
color: 'var(--tui-neutral-fill)',
|
||||
icon: 'tuiIconPower',
|
||||
status: 'neutral',
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connected',
|
||||
color: 'var(--tui-success-fill)',
|
||||
icon: 'tuiIconCloud',
|
||||
status: 'success',
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
|
||||
import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { Subject } from 'rxjs'
|
||||
import { HeaderMenuComponent } from './menu.component'
|
||||
import { HeaderNotificationsComponent } from './notifications.component'
|
||||
import { SidebarDirective } from 'src/app/components/sidebar-host.component'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'header-corner',
|
||||
template: `
|
||||
<ng-content />
|
||||
<tui-badged-content
|
||||
*tuiLet="notificationService.unreadCount$ | async as unread"
|
||||
[style.--tui-radius.%]="50"
|
||||
>
|
||||
<tui-badge-notification *ngIf="unread" tuiSlot="top" size="s">
|
||||
{{ unread }}
|
||||
</tui-badge-notification>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconBellLarge"
|
||||
appearance="icon"
|
||||
size="s"
|
||||
[style.color]="'var(--tui-text-01)'"
|
||||
(click)="handleNotificationsClick(unread || 0)"
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
</tui-badged-content>
|
||||
<header-menu></header-menu>
|
||||
<header-notifications
|
||||
(onEmpty)="this.open$.next(false)"
|
||||
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
|
||||
/>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem 0 1.75rem;
|
||||
--clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 1.75rem 100%);
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) tui-badged-content {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderMenuComponent,
|
||||
HeaderNotificationsComponent,
|
||||
SidebarDirective,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiBadgedContentModule,
|
||||
TuiButtonModule,
|
||||
TuiLetModule,
|
||||
TuiSidebarModule,
|
||||
],
|
||||
})
|
||||
export class HeaderCornerComponent {
|
||||
private readonly router = inject(Router)
|
||||
readonly notificationService = inject(NotificationService)
|
||||
|
||||
@ViewChild(HeaderNotificationsComponent, { read: ElementRef })
|
||||
private readonly panel?: ElementRef<HTMLElement>
|
||||
|
||||
private readonly _ = this.router.events.subscribe(() => {
|
||||
this.open$.next(false)
|
||||
})
|
||||
|
||||
readonly open$ = new Subject<boolean>()
|
||||
|
||||
@HostListener('document:click.capture', ['$event.target'])
|
||||
onClick(target: EventTarget | null) {
|
||||
if (
|
||||
tuiIsElement(target) &&
|
||||
this.panel?.nativeElement &&
|
||||
!tuiContainsOrAfter(this.panel.nativeElement, target)
|
||||
) {
|
||||
this.open$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleNotificationsClick(unread: number) {
|
||||
if (unread) {
|
||||
this.open$.next(true)
|
||||
} else {
|
||||
this.router.navigateByUrl('/portal/system/notifications')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
IsActiveMatchOptions,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
} from '@angular/router'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { HeaderConnectionComponent } from './connection.component'
|
||||
import { HeaderHomeComponent } from './home.component'
|
||||
import { HeaderCornerComponent } from './corner.component'
|
||||
import { HeaderBreadcrumbComponent } from './breadcrumb.component'
|
||||
import { HeaderSnekDirective } from './snek.directive'
|
||||
import { HeaderMobileComponent } from './mobile.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
|
||||
|
||||
@Component({
|
||||
selector: 'header[appHeader]',
|
||||
template: `
|
||||
<a headerHome routerLink="/portal/dashboard" routerLinkActive="active">
|
||||
<div class="plaque"></div>
|
||||
</a>
|
||||
@for (item of breadcrumbs$ | async; track $index) {
|
||||
<a
|
||||
routerLinkActive="active"
|
||||
[routerLink]="item.routerLink"
|
||||
[routerLinkActiveOptions]="options"
|
||||
[headerBreadcrumb]="item"
|
||||
>
|
||||
<div class="plaque"></div>
|
||||
</a>
|
||||
}
|
||||
<div [style.flex]="1" [headerMobile]="breadcrumbs$ | async">
|
||||
<div class="plaque"></div>
|
||||
<img
|
||||
[appSnek]="(snekScore$ | async) || 0"
|
||||
class="snek"
|
||||
alt="Play Snake"
|
||||
src="assets/img/icons/snek.png"
|
||||
/>
|
||||
</div>
|
||||
<header-connection><div class="plaque"></div></header-connection>
|
||||
<header-corner><div class="plaque"></div></header-corner>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
height: 3.5rem;
|
||||
padding: 0.375rem;
|
||||
--clip-path: polygon(
|
||||
0% 0%,
|
||||
calc(100% - 1.75rem) 0%,
|
||||
100% 100%,
|
||||
1.75rem 100%
|
||||
);
|
||||
|
||||
> * {
|
||||
@include transition(all);
|
||||
position: relative;
|
||||
margin-left: -1.25rem;
|
||||
backdrop-filter: blur(1rem);
|
||||
clip-path: var(--clip-path);
|
||||
|
||||
&:active {
|
||||
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
&:has([data-connection='error']) {
|
||||
--status: var(--tui-error-fill);
|
||||
}
|
||||
|
||||
&:has([data-connection='warning']) {
|
||||
--status: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
&:has([data-connection='neutral']) {
|
||||
--status: var(--tui-neutral-fill);
|
||||
}
|
||||
|
||||
&:has([data-connection='success']) {
|
||||
--status: var(--tui-success-fill);
|
||||
}
|
||||
}
|
||||
|
||||
header-connection .plaque::before {
|
||||
box-shadow:
|
||||
inset 0 1px rgba(255, 255, 255, 0.25),
|
||||
inset 0 -0.25rem var(--tui-success-fill);
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
header-corner .plaque::before {
|
||||
box-shadow:
|
||||
inset 0 1px rgb(255 255 255 / 25%),
|
||||
inset -0.375rem 0 var(--status);
|
||||
}
|
||||
}
|
||||
|
||||
.plaque {
|
||||
@include transition(opacity);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
filter: url(#round-corners);
|
||||
opacity: 0.5;
|
||||
|
||||
.active & {
|
||||
opacity: 0.75;
|
||||
|
||||
&::before {
|
||||
// TODO: Theme
|
||||
background: #363636;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
@include transition(all);
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
clip-path: var(--clip-path);
|
||||
// TODO: Theme
|
||||
background: #5f5f5f;
|
||||
box-shadow: inset 0 1px rgb(255 255 255 / 25%);
|
||||
}
|
||||
}
|
||||
|
||||
.snek {
|
||||
@include center-top();
|
||||
@include transition(opacity);
|
||||
right: 2rem;
|
||||
width: 1rem;
|
||||
opacity: 0.2;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
AsyncPipe,
|
||||
HeaderConnectionComponent,
|
||||
HeaderHomeComponent,
|
||||
HeaderCornerComponent,
|
||||
HeaderSnekDirective,
|
||||
HeaderBreadcrumbComponent,
|
||||
HeaderMobileComponent,
|
||||
],
|
||||
})
|
||||
export class HeaderComponent {
|
||||
readonly options = OPTIONS
|
||||
readonly breadcrumbs$ = inject(BreadcrumbsService)
|
||||
readonly snekScore$ = inject(PatchDB<DataModel>).watch$(
|
||||
'ui',
|
||||
'gaming',
|
||||
'snake',
|
||||
'highScore',
|
||||
)
|
||||
}
|
||||
|
||||
const OPTIONS: IsActiveMatchOptions = {
|
||||
paths: 'exact',
|
||||
queryParams: 'ignored',
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'a[headerHome]',
|
||||
template: `
|
||||
<ng-content />
|
||||
<tui-icon icon="/assets/img/icons/home.svg" [style.font-size.rem]="2" />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 2.5rem 0 1rem;
|
||||
margin: 0 !important;
|
||||
|
||||
--clip-path: polygon(
|
||||
calc(100% - 1.75rem) 0%,
|
||||
calc(100% - 0.875rem) 50%,
|
||||
calc(100% - 1.75rem) 100%,
|
||||
0% 100%,
|
||||
0% 0%
|
||||
);
|
||||
|
||||
&.active {
|
||||
--clip-path: polygon(
|
||||
calc(100% - 1.75rem) 0%,
|
||||
calc(100% - 0.875rem) 50%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
0% 0%
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIconModule],
|
||||
})
|
||||
export class HeaderHomeComponent {}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { ABOUT } from './about.component'
|
||||
import { getAllPackages } from 'src/app/utils/get-package-data'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { HeaderConnectionComponent } from './connection.component'
|
||||
|
||||
@Component({
|
||||
selector: 'header-menu',
|
||||
template: `
|
||||
<tui-hosted-dropdown [content]="content" [tuiDropdownMaxHeight]="9999">
|
||||
<button tuiIconButton appearance="">
|
||||
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<header-connection class="status">
|
||||
<h3 class="title">StartOS</h3>
|
||||
</header-connection>
|
||||
<button tuiOption class="item" (click)="about()">
|
||||
<tui-icon icon="tuiIconInfo" />
|
||||
About this server
|
||||
</button>
|
||||
<tui-opt-group>
|
||||
@for (link of links; track $index) {
|
||||
<a
|
||||
tuiOption
|
||||
class="item"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="link.href"
|
||||
>
|
||||
<tui-icon [icon]="link.icon" />
|
||||
{{ link.name }}
|
||||
<tui-icon class="external" icon="tuiIconArrowUpRight" />
|
||||
</a>
|
||||
}
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
@for (item of system; track $index) {
|
||||
<button tuiOption class="item" (click)="prompt(item.action)">
|
||||
<tui-icon [icon]="item.icon" />
|
||||
{{ item.action }}
|
||||
</button>
|
||||
}
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="logout()">
|
||||
<tui-icon icon="tuiIconLogOut" />
|
||||
Logout
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex !important;
|
||||
font-size: 0;
|
||||
padding: 0 0.5rem;
|
||||
height: 2rem;
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 auto 0 0;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.external {
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
HeaderConnectionComponent,
|
||||
],
|
||||
})
|
||||
export class HeaderMenuComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly links = [
|
||||
{
|
||||
name: 'User Manual',
|
||||
icon: 'tuiIconBookOpen',
|
||||
href: 'https://docs.start9.com/0.3.5.x/user-manual',
|
||||
},
|
||||
{
|
||||
name: 'Contact Support',
|
||||
icon: 'tuiIconHeadphones',
|
||||
href: 'https://start9.com/contact',
|
||||
},
|
||||
{
|
||||
name: 'Donate to Start9',
|
||||
icon: 'tuiIconDollarSign',
|
||||
href: 'https://donate.start9.com',
|
||||
},
|
||||
]
|
||||
|
||||
readonly system = [
|
||||
{
|
||||
icon: 'tuiIconTool',
|
||||
action: 'System Rebuild',
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconRefreshCw',
|
||||
action: 'Restart',
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconPower',
|
||||
action: 'Shutdown',
|
||||
},
|
||||
] as const
|
||||
|
||||
about() {
|
||||
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.api.logout({}).catch(e => console.error('Failed to log out', e))
|
||||
this.auth.setUnverified()
|
||||
}
|
||||
|
||||
async prompt(action: keyof typeof METHODS) {
|
||||
const minutes =
|
||||
action === 'System Rebuild'
|
||||
? Object.keys(await getAllPackages(this.patch)).length * 2
|
||||
: ''
|
||||
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, getOptions(action, minutes))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.api[METHODS[action]]({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const METHODS = {
|
||||
Restart: 'restartServer',
|
||||
Shutdown: 'shutdownServer',
|
||||
'System Rebuild': 'systemRebuild',
|
||||
} as const
|
||||
|
||||
function getOptions(
|
||||
key: keyof typeof METHODS,
|
||||
minutes: unknown,
|
||||
): Partial<TuiDialogOptions<TuiPromptData>> {
|
||||
switch (key) {
|
||||
case 'Restart':
|
||||
return {
|
||||
label: 'Restart',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to restart your server? It can take several minutes to come back online.',
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
case 'Shutdown':
|
||||
return {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
|
||||
yes: 'Shutdown',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This action will tear down all service containers and rebuild them from scratch. No data will be deleted. This action is useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues. It may take up to ${minutes} minutes to complete. During this time, you will lose all connectivity to your server.`,
|
||||
yes: 'Rebuild',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { Breadcrumb } from 'src/app/services/breadcrumbs.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: '[headerMobile]',
|
||||
template: `
|
||||
@if (headerMobile?.length) {
|
||||
<a [routerLink]="back" [style.padding.rem]="0.75">
|
||||
<tui-icon icon="tuiIconArrowLeft" />
|
||||
</a>
|
||||
}
|
||||
<span class="title">{{ title }}</span>
|
||||
<ng-content />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
|
||||
> * {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
margin: 0;
|
||||
--clip-path: polygon(
|
||||
0% 0%,
|
||||
calc(100% - 1.75rem) 0%,
|
||||
100% 100%,
|
||||
0% 100%
|
||||
);
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
@include text-overflow();
|
||||
max-width: calc(100% - 5rem);
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIconModule, RouterLink],
|
||||
})
|
||||
export class HeaderMobileComponent {
|
||||
@Input() headerMobile: readonly Breadcrumb[] | null = []
|
||||
|
||||
get title() {
|
||||
return this.headerMobile?.[this.headerMobile?.length - 1]?.title || ''
|
||||
}
|
||||
|
||||
get back() {
|
||||
return (
|
||||
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
|
||||
'/portal/dashboard'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import { TuiLineClampModule } from '@taiga-ui/kit'
|
||||
import { ServerNotification } from 'src/app/services/api/api.types'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
|
||||
@Component({
|
||||
selector: 'header-notification',
|
||||
template: `
|
||||
<tui-svg
|
||||
style="align-self: flex-start; margin: 0.25rem 0;"
|
||||
[style.color]="color"
|
||||
[src]="icon"
|
||||
></tui-svg>
|
||||
<div tuiTitle>
|
||||
<div tuiSubtitle><ng-content></ng-content></div>
|
||||
<div [style.color]="color">
|
||||
{{ notification.title }}
|
||||
</div>
|
||||
<tui-line-clamp
|
||||
tuiSubtitle
|
||||
style="pointer-events: none"
|
||||
[linesLimit]="4"
|
||||
[lineHeight]="16"
|
||||
[content]="notification.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<div style="display: flex; gap: 0.5rem; padding-top: 0.5rem;">
|
||||
<button
|
||||
*ngIf="notification.code === 1"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewReport(notification)"
|
||||
>
|
||||
View Report
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="overflow"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewFull(notification)"
|
||||
>
|
||||
View full
|
||||
</button>
|
||||
<ng-content select="a"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="button"></ng-content>
|
||||
`,
|
||||
styles: [':host { box-shadow: 0 1px var(--tui-clear); }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiSvgModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
TuiLineClampModule,
|
||||
],
|
||||
})
|
||||
export class HeaderNotificationComponent<T extends number> {
|
||||
readonly service = inject(NotificationService)
|
||||
|
||||
@Input({ required: true }) notification!: ServerNotification<T>
|
||||
|
||||
overflow = false
|
||||
|
||||
get color(): string {
|
||||
return this.service.getColor(this.notification)
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
return this.service.getIcon(this.notification)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Output,
|
||||
inject,
|
||||
EventEmitter,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAvatarStackModule,
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { Subject, first, tap } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { HeaderNotificationComponent } from './notification.component'
|
||||
import { toRouterLink } from 'src/app/utils/to-router-link'
|
||||
import {
|
||||
ServerNotification,
|
||||
ServerNotifications,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
import { ToManifestPipe } from '../../pipes/to-manifest'
|
||||
|
||||
@Component({
|
||||
selector: 'header-notifications',
|
||||
template: `
|
||||
<ng-container *ngIf="notifications$ | async as notifications">
|
||||
<h3 class="g-title" style="padding: 0 1rem">
|
||||
Notifications
|
||||
<a
|
||||
*ngIf="notifications.length"
|
||||
style="margin-left: auto; text-transform: none; font-size: 0.9rem; font-weight: 600;"
|
||||
(click)="markAllSeen(notifications[0].id)"
|
||||
>
|
||||
Mark All Seen
|
||||
</a>
|
||||
</h3>
|
||||
<tui-scrollbar *ngIf="packageData$ | async as packageData">
|
||||
<header-notification
|
||||
*ngFor="let not of notifications; let i = index"
|
||||
tuiCell
|
||||
[notification]="not"
|
||||
>
|
||||
<ng-container *ngIf="not.packageId as pkgId">
|
||||
{{
|
||||
packageData[pkgId]
|
||||
? (packageData[pkgId] | toManifest).title
|
||||
: pkgId
|
||||
}}
|
||||
</ng-container>
|
||||
<button
|
||||
style="align-self: flex-start; flex-shrink: 0;"
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconMinusCircle"
|
||||
(click)="markSeen(notifications, not)"
|
||||
></button>
|
||||
<a
|
||||
*ngIf="not.packageId && packageData[not.packageId]"
|
||||
tuiButton
|
||||
size="xs"
|
||||
appearance="secondary"
|
||||
[routerLink]="getLink(not.packageId || '')"
|
||||
>
|
||||
View Service
|
||||
</a>
|
||||
</header-notification>
|
||||
</tui-scrollbar>
|
||||
<a
|
||||
style="margin: 2rem; text-align: center; font-size: 0.9rem; font-weight: 600;"
|
||||
[routerLink]="'/portal/system/notifications'"
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 22rem;
|
||||
max-width: 80vw;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TuiForModule,
|
||||
TuiScrollbarModule,
|
||||
TuiButtonModule,
|
||||
HeaderNotificationComponent,
|
||||
TuiCellModule,
|
||||
TuiAvatarStackModule,
|
||||
TuiTitleModule,
|
||||
ToManifestPipe,
|
||||
],
|
||||
})
|
||||
export class HeaderNotificationsComponent {
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly service = inject(NotificationService)
|
||||
|
||||
readonly packageData$ = this.patch.watch$('packageData').pipe(first())
|
||||
|
||||
readonly notifications$ = new Subject<ServerNotifications>()
|
||||
|
||||
@Output() onEmpty = new EventEmitter()
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.patch
|
||||
.watch$('serverInfo', 'unreadNotifications', 'recent')
|
||||
.pipe(
|
||||
tap(recent => this.notifications$.next(recent)),
|
||||
first(),
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
markSeen(
|
||||
current: ServerNotifications,
|
||||
notification: ServerNotification<number>,
|
||||
) {
|
||||
this.notifications$.next(current.filter(c => c.id !== notification.id))
|
||||
|
||||
if (current.length === 1) this.onEmpty.emit()
|
||||
|
||||
this.service.markSeen([notification])
|
||||
}
|
||||
|
||||
markAllSeen(latestId: number) {
|
||||
this.notifications$.next([])
|
||||
|
||||
this.service.markSeenAll(latestId)
|
||||
|
||||
this.onEmpty.emit()
|
||||
}
|
||||
|
||||
getLink(id: string) {
|
||||
return toRouterLink(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="canvas-center">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<strong>Score: {{ score }}</strong>
|
||||
<span>High Score: {{ highScore }}</span>
|
||||
<button tuiButton (click)="dismiss()">Save and Quit</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.canvas-center {
|
||||
min-height: 50vh;
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 32px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [TuiButtonModule],
|
||||
})
|
||||
export class HeaderSnekComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly dialog =
|
||||
inject<TuiDialogContext<number, number>>(POLYMORPHEUS_CONTEXT)
|
||||
|
||||
highScore: number = this.dialog.data
|
||||
score = 0
|
||||
|
||||
private readonly speed = 45
|
||||
private readonly width = 40
|
||||
private readonly height = 26
|
||||
private grid = NaN
|
||||
|
||||
private readonly startingLength = 4
|
||||
|
||||
private xDown?: number
|
||||
private yDown?: number
|
||||
private canvas!: HTMLCanvasElement
|
||||
private image!: HTMLImageElement
|
||||
private context!: CanvasRenderingContext2D
|
||||
|
||||
private snake: any
|
||||
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
|
||||
|
||||
private moveQueue: String[] = []
|
||||
private destroyed = false
|
||||
|
||||
dismiss() {
|
||||
this.dialog.completeWith(this.highScore)
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
keyEvent(e: KeyboardEvent) {
|
||||
this.moveQueue.push(e.key)
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
touchStart(e: TouchEvent) {
|
||||
this.handleTouchStart(e)
|
||||
}
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
touchMove(e: TouchEvent) {
|
||||
this.handleTouchMove(e)
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
sizeChange() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.init()
|
||||
|
||||
this.image = new Image()
|
||||
this.image.onload = () => {
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
}
|
||||
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = this.document.querySelector('canvas#game')!
|
||||
this.canvas.style.border = '1px solid #e0e0e0'
|
||||
this.context = this.canvas.getContext('2d')!
|
||||
const container = this.document.querySelector('.canvas-center')!
|
||||
this.grid = Math.min(
|
||||
Math.floor(container.clientWidth / this.width),
|
||||
Math.floor(container.clientHeight / this.height),
|
||||
)
|
||||
this.snake = {
|
||||
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
|
||||
y: this.grid * Math.floor(this.height / 2),
|
||||
// snake velocity. moves one grid length every frame in either the x or y direction
|
||||
dx: this.grid,
|
||||
dy: 0,
|
||||
// keep track of all grids the snake body occupies
|
||||
cells: [],
|
||||
// length of the snake. grows when eating an bitcoin
|
||||
maxCells: this.startingLength,
|
||||
}
|
||||
this.bitcoin = {
|
||||
x: this.getRandomInt(0, this.width) * this.grid,
|
||||
y: this.getRandomInt(0, this.height) * this.grid,
|
||||
}
|
||||
|
||||
this.canvas.width = this.grid * this.width
|
||||
this.canvas.height = this.grid * this.height
|
||||
this.context.imageSmoothingEnabled = false
|
||||
}
|
||||
|
||||
getTouches(evt: TouchEvent) {
|
||||
return evt.touches
|
||||
}
|
||||
|
||||
handleTouchStart(evt: TouchEvent) {
|
||||
const firstTouch = this.getTouches(evt)[0]
|
||||
this.xDown = firstTouch.clientX
|
||||
this.yDown = firstTouch.clientY
|
||||
}
|
||||
|
||||
handleTouchMove(evt: TouchEvent) {
|
||||
if (!this.xDown || !this.yDown) {
|
||||
return
|
||||
}
|
||||
|
||||
var xUp = evt.touches[0].clientX
|
||||
var yUp = evt.touches[0].clientY
|
||||
|
||||
var xDiff = this.xDown - xUp
|
||||
var yDiff = this.yDown - yUp
|
||||
|
||||
if (Math.abs(xDiff) > Math.abs(yDiff)) {
|
||||
/*most significant*/
|
||||
if (xDiff > 0) {
|
||||
this.moveQueue.push('ArrowLeft')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowRight')
|
||||
}
|
||||
} else {
|
||||
if (yDiff > 0) {
|
||||
this.moveQueue.push('ArrowUp')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowDown')
|
||||
}
|
||||
}
|
||||
/* reset values */
|
||||
this.xDown = undefined
|
||||
this.yDown = undefined
|
||||
}
|
||||
|
||||
// game loop
|
||||
async loop() {
|
||||
if (this.destroyed) return
|
||||
|
||||
await pauseFor(this.speed)
|
||||
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// move snake by its velocity
|
||||
this.snake.x += this.snake.dx
|
||||
this.snake.y += this.snake.dy
|
||||
|
||||
if (this.moveQueue.length) {
|
||||
const move = this.moveQueue.shift()
|
||||
// left arrow key
|
||||
if (move === 'ArrowLeft' && this.snake.dx === 0) {
|
||||
this.snake.dx = -this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// up arrow key
|
||||
else if (move === 'ArrowUp' && this.snake.dy === 0) {
|
||||
this.snake.dy = -this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
// right arrow key
|
||||
else if (move === 'ArrowRight' && this.snake.dx === 0) {
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// down arrow key
|
||||
else if (move === 'ArrowDown' && this.snake.dy === 0) {
|
||||
this.snake.dy = this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
}
|
||||
|
||||
// edge death
|
||||
if (
|
||||
this.snake.x < 0 ||
|
||||
this.snake.y < 0 ||
|
||||
this.snake.x >= this.canvas.width ||
|
||||
this.snake.y >= this.canvas.height
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
|
||||
// keep track of where snake has been. front of the array is always the head
|
||||
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
|
||||
|
||||
// remove cells as we move away from them
|
||||
if (this.snake.cells.length > this.snake.maxCells) {
|
||||
this.snake.cells.pop()
|
||||
}
|
||||
|
||||
// draw bitcoin
|
||||
this.context.fillStyle = '#ff4961'
|
||||
this.context.drawImage(
|
||||
this.image,
|
||||
this.bitcoin.x - 1,
|
||||
this.bitcoin.y - 1,
|
||||
this.grid + 2,
|
||||
this.grid + 2,
|
||||
)
|
||||
|
||||
// draw snake one cell at a time
|
||||
this.context.fillStyle = '#2fdf75'
|
||||
|
||||
const firstCell = this.snake.cells[0]
|
||||
|
||||
for (let index = 0; index < this.snake.cells.length; index++) {
|
||||
const cell = this.snake.cells[index]
|
||||
|
||||
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
|
||||
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
|
||||
|
||||
// snake ate bitcoin
|
||||
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
|
||||
this.score++
|
||||
this.highScore = Math.max(this.score, this.highScore)
|
||||
this.snake.maxCells++
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
// check collision with all cells after this one (modified bubble sort)
|
||||
// snake occupies same space as a body part. reset game
|
||||
if (
|
||||
firstCell.x === this.snake.cells[index].x &&
|
||||
firstCell.y === this.snake.cells[index].y
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
death() {
|
||||
this.snake.x =
|
||||
this.grid * (Math.floor(this.width / 2) - this.startingLength)
|
||||
this.snake.y = this.grid * Math.floor(this.height / 2)
|
||||
this.snake.cells = []
|
||||
this.snake.maxCells = this.startingLength
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
|
||||
this.score = 0
|
||||
}
|
||||
|
||||
getRandomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min)) + min
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Directive, HostListener, inject, Input } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { HeaderSnekComponent } from './snek.component'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: 'img[appSnek]',
|
||||
})
|
||||
export class HeaderSnekDirective {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
@Input()
|
||||
appSnek = 0
|
||||
|
||||
@HostListener('click')
|
||||
async onClick() {
|
||||
this.dialogs
|
||||
.open<number>(new PolymorpheusComponent(HeaderSnekComponent), {
|
||||
label: 'Snake!',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
data: this.appSnek,
|
||||
})
|
||||
.pipe(filter(score => score > this.appSnek))
|
||||
.subscribe(async score => {
|
||||
const loader = this.loader.open('Saving high score...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<number>(
|
||||
['gaming', 'snake', 'high-score'],
|
||||
score,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { AddressItemComponent } from './address-item.component'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { AddressDetails, AddressesService } from './interface.utils'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-address-group',
|
||||
template: `
|
||||
<div>
|
||||
@if (addresses.length) {
|
||||
<button
|
||||
class="icon-add-btn"
|
||||
tuiIconButton
|
||||
appearance="secondary"
|
||||
iconLeft="tuiIconPlus"
|
||||
(click)="service.add()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
}
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@for (address of addresses; track $index) {
|
||||
<app-address-item [label]="address.label" [address]="address.url" />
|
||||
} @empty {
|
||||
<button
|
||||
tuiButton
|
||||
iconLeft="tuiIconPlus"
|
||||
[style.align-self]="'flex-start'"
|
||||
(click)="service.add()"
|
||||
>
|
||||
Add Address
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
imports: [AddressItemComponent, TuiButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: `
|
||||
.icon-add-btn {
|
||||
float: right;
|
||||
margin-left: 2rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AddressGroupComponent {
|
||||
readonly service = inject(AddressesService)
|
||||
|
||||
@Input({ required: true }) addresses!: AddressDetails[]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiBadgeModule,
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
|
||||
import { mask } from 'src/app/utils/mask'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
import { AddressesService } from './interface.utils'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-address-item',
|
||||
template: `
|
||||
<div tuiCell>
|
||||
<tui-badge appearance="success">
|
||||
{{ label }}
|
||||
</tui-badge>
|
||||
<h3 tuiTitle>
|
||||
<span tuiSubtitle>
|
||||
{{ interface.serviceInterface.masked ? mask : address }}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
*ngIf="interface.serviceInterface.type === 'ui'"
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconExternalLink"
|
||||
appearance="icon"
|
||||
(click)="launch(address)"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconGrid"
|
||||
appearance="icon"
|
||||
(click)="showQR(address)"
|
||||
>
|
||||
Show QR code
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconCopy"
|
||||
appearance="icon"
|
||||
(click)="copyService.copy(address)"
|
||||
>
|
||||
Copy URL
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconTrash"
|
||||
appearance="icon"
|
||||
(click)="service.remove()"
|
||||
>
|
||||
Destroy
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
NgIf,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
TuiBadgeModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AddressItemComponent {
|
||||
private readonly window = inject(WINDOW)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly service = inject(AddressesService)
|
||||
readonly copyService = inject(CopyService)
|
||||
readonly interface = inject(InterfaceComponent)
|
||||
|
||||
@Input() label?: string
|
||||
@Input({ required: true }) address!: string
|
||||
|
||||
get mask(): string {
|
||||
return mask(this.address, 64)
|
||||
}
|
||||
|
||||
launch(url: string): void {
|
||||
this.window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
showQR(data: string) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(QRModal), {
|
||||
size: 'auto',
|
||||
data,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Directive, Input } from '@angular/core'
|
||||
import { AddressesService } from '../interface.utils'
|
||||
import { inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/routes/portal/components/form.component'
|
||||
import { getClearnetSpec } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { InterfaceComponent } from '../interface.component'
|
||||
|
||||
type ClearnetForm = {
|
||||
domain: string
|
||||
subdomain: string | null
|
||||
}
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[clearnetAddresses]',
|
||||
providers: [
|
||||
{ provide: AddressesService, useExisting: ClearnetAddressesDirective },
|
||||
],
|
||||
})
|
||||
export class ClearnetAddressesDirective implements AddressesService {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly interface = inject(InterfaceComponent)
|
||||
|
||||
@Input({ required: true }) network!: NetworkInfo
|
||||
|
||||
async add() {
|
||||
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
|
||||
label: 'Select Domain/Subdomain',
|
||||
data: {
|
||||
spec: await getClearnetSpec(this.network),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Manage domains',
|
||||
link: 'portal/system/settings/domains',
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async value => this.save(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
async remove() {}
|
||||
|
||||
private async save(domainInfo: ClearnetForm): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
|
||||
try {
|
||||
if (this.interface.packageContext) {
|
||||
await this.api.setInterfaceClearnetAddress({
|
||||
...this.interface.packageContext,
|
||||
domainInfo,
|
||||
})
|
||||
} else {
|
||||
await this.api.setServerClearnetAddress({ domainInfo })
|
||||
}
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Directive } from '@angular/core'
|
||||
import { AddressesService } from '../interface.utils'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[localAddresses]',
|
||||
providers: [
|
||||
{ provide: AddressesService, useExisting: LocalAddressesDirective },
|
||||
],
|
||||
})
|
||||
export class LocalAddressesDirective implements AddressesService {
|
||||
async add() {}
|
||||
async remove() {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Directive } from '@angular/core'
|
||||
import { AddressesService } from '../interface.utils'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[torAddresses]',
|
||||
providers: [
|
||||
{ provide: AddressesService, useExisting: TorAddressesDirective },
|
||||
],
|
||||
})
|
||||
export class TorAddressesDirective implements AddressesService {
|
||||
async add() {}
|
||||
async remove() {}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AddressGroupComponent } from 'src/app/routes/portal/components/interfaces/address-group.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { AddressDetails } from './interface.utils'
|
||||
import { ClearnetAddressesDirective } from './directives/clearnet.directive'
|
||||
import { LocalAddressesDirective } from './directives/local.directive'
|
||||
import { TorAddressesDirective } from './directives/tor.directive'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface',
|
||||
template: `
|
||||
<h3 class="g-title">Clearnet</h3>
|
||||
<app-address-group
|
||||
*ngIf="network$ | async as network"
|
||||
clearnetAddresses
|
||||
tuiCardLarge="compact"
|
||||
tuiSurface="elevated"
|
||||
[network]="network"
|
||||
[addresses]="serviceInterface.addresses.clearnet"
|
||||
>
|
||||
<em>
|
||||
Add a clearnet address to expose this interface on the Internet.
|
||||
Clearnet addresses are fully public and not anonymous.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>Learn More</strong>
|
||||
</a>
|
||||
</em>
|
||||
</app-address-group>
|
||||
|
||||
<h3 class="g-title">Tor</h3>
|
||||
<app-address-group
|
||||
torAddresses
|
||||
tuiCardLarge="compact"
|
||||
tuiSurface="elevated"
|
||||
[addresses]="serviceInterface.addresses.tor"
|
||||
>
|
||||
<em>
|
||||
Add an onion address to anonymously expose this interface on the
|
||||
darknet. Onion addresses can only be reached over the Tor network.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>Learn More</strong>
|
||||
</a>
|
||||
</em>
|
||||
</app-address-group>
|
||||
|
||||
<h3 class="g-title">Local</h3>
|
||||
<app-address-group
|
||||
localAddresses
|
||||
tuiCardLarge="compact"
|
||||
tuiSurface="elevated"
|
||||
[addresses]="serviceInterface.addresses.local"
|
||||
>
|
||||
<em>
|
||||
Add a local address to expose this interface on your Local Area Network
|
||||
(LAN). Local addresses can only be accessed by devices connected to the
|
||||
same LAN as your server, either directly or using a VPN.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>Learn More</strong>
|
||||
</a>
|
||||
</em>
|
||||
</app-address-group>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AddressGroupComponent,
|
||||
TuiCardModule,
|
||||
TuiSurfaceModule,
|
||||
ClearnetAddressesDirective,
|
||||
TorAddressesDirective,
|
||||
LocalAddressesDirective,
|
||||
],
|
||||
})
|
||||
export class InterfaceComponent {
|
||||
readonly network$ = inject(PatchDB<DataModel>).watch$('serverInfo', 'network')
|
||||
|
||||
@Input() packageContext?: {
|
||||
packageId: string
|
||||
interfaceId: string
|
||||
}
|
||||
@Input({ required: true }) serviceInterface!: ServiceInterfaceWithAddresses
|
||||
}
|
||||
|
||||
export type ServiceInterfaceWithAddresses = T.ServiceInterface & {
|
||||
addresses: {
|
||||
clearnet: AddressDetails[]
|
||||
local: AddressDetails[]
|
||||
tor: AddressDetails[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { CB, CT, T } from '@start9labs/start-sdk'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiPromptData } from '@taiga-ui/kit'
|
||||
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
export abstract class AddressesService {
|
||||
abstract add(): Promise<void>
|
||||
abstract remove(): Promise<void>
|
||||
}
|
||||
|
||||
export const REMOVE: Partial<TuiDialogOptions<TuiPromptData>> = {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Remove clearnet address?',
|
||||
yes: 'Remove',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
|
||||
export function getClearnetSpec({
|
||||
domains,
|
||||
start9ToSubdomain,
|
||||
}: NetworkInfo): Promise<CT.InputSpec> {
|
||||
const start9ToDomain = `${start9ToSubdomain?.value}.start9.to`
|
||||
const base = start9ToSubdomain ? { [start9ToDomain]: start9ToDomain } : {}
|
||||
|
||||
const values = domains.reduce((prev, curr) => {
|
||||
return {
|
||||
[curr.value]: curr.value,
|
||||
...prev,
|
||||
}
|
||||
}, base)
|
||||
|
||||
return configBuilderToSpec(
|
||||
CB.Config.of({
|
||||
domain: CB.Value.select({
|
||||
name: 'Domain',
|
||||
required: { default: null },
|
||||
values,
|
||||
}),
|
||||
subdomain: CB.Value.text({
|
||||
name: 'Subdomain',
|
||||
required: false,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export type AddressDetails = {
|
||||
label?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function getAddresses(
|
||||
serviceInterface: T.ServiceInterfaceWithHostInfo,
|
||||
): {
|
||||
clearnet: AddressDetails[]
|
||||
local: AddressDetails[]
|
||||
tor: AddressDetails[]
|
||||
} {
|
||||
const host = serviceInterface.hostInfo
|
||||
const addressInfo = serviceInterface.addressInfo
|
||||
const username = addressInfo.username ? addressInfo.username + '@' : ''
|
||||
const suffix = addressInfo.suffix || ''
|
||||
|
||||
const hostnames =
|
||||
host.kind === 'multi'
|
||||
? host.hostnames
|
||||
: host.hostname
|
||||
? [host.hostname]
|
||||
: []
|
||||
|
||||
const clearnet: AddressDetails[] = []
|
||||
const local: AddressDetails[] = []
|
||||
const tor: AddressDetails[] = []
|
||||
|
||||
hostnames.forEach(h => {
|
||||
let scheme = ''
|
||||
let port = ''
|
||||
|
||||
if (h.hostname.sslPort) {
|
||||
port = h.hostname.sslPort === 443 ? '' : `:${h.hostname.sslPort}`
|
||||
scheme = addressInfo.bindOptions.addSsl?.scheme
|
||||
? `${addressInfo.bindOptions.addSsl.scheme}://`
|
||||
: ''
|
||||
}
|
||||
|
||||
if (h.hostname.port) {
|
||||
port = h.hostname.port === 80 ? '' : `:${h.hostname.port}`
|
||||
scheme = addressInfo.bindOptions.scheme
|
||||
? `${addressInfo.bindOptions.scheme}://`
|
||||
: ''
|
||||
}
|
||||
|
||||
if (h.kind === 'onion') {
|
||||
tor.push({
|
||||
label: h.hostname.sslPort ? 'HTTPS' : 'HTTP',
|
||||
url: toHref(scheme, username, h.hostname.value, port, suffix),
|
||||
})
|
||||
} else {
|
||||
const hostnameKind = h.hostname.kind
|
||||
|
||||
if (hostnameKind === 'domain') {
|
||||
tor.push({
|
||||
url: toHref(
|
||||
scheme,
|
||||
username,
|
||||
`${h.hostname.subdomain}.${h.hostname.domain}`,
|
||||
port,
|
||||
suffix,
|
||||
),
|
||||
})
|
||||
} else {
|
||||
local.push({
|
||||
label:
|
||||
hostnameKind === 'local'
|
||||
? 'Local'
|
||||
: `${h.networkInterfaceId} (${hostnameKind})`,
|
||||
url: toHref(scheme, username, h.hostname.value, port, suffix),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
clearnet,
|
||||
local,
|
||||
tor,
|
||||
}
|
||||
}
|
||||
|
||||
function toHref(
|
||||
scheme: string,
|
||||
username: string,
|
||||
hostname: string,
|
||||
port: string,
|
||||
suffix: string,
|
||||
): string {
|
||||
return `${scheme}${username}${hostname}${port}${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Directive, HostListener, inject, Input } from '@angular/core'
|
||||
import {
|
||||
convertAnsi,
|
||||
DownloadHTMLService,
|
||||
ErrorService,
|
||||
FetchLogsReq,
|
||||
FetchLogsRes,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { LogsComponent } from './logs.component'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: 'button[logsDownload]',
|
||||
})
|
||||
export class LogsDownloadDirective {
|
||||
private readonly component = inject(LogsComponent)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||
|
||||
@Input({ required: true })
|
||||
logsDownload!: (params: FetchLogsReq) => Promise<FetchLogsRes>
|
||||
|
||||
@HostListener('click')
|
||||
async download() {
|
||||
const loader = this.loader.open('Processing 10,000 logs...').subscribe()
|
||||
|
||||
try {
|
||||
const { entries } = await this.logsDownload({
|
||||
before: true,
|
||||
limit: 10000,
|
||||
})
|
||||
|
||||
this.downloadHtml.download(
|
||||
`${this.component.context}-logs.html`,
|
||||
convertAnsi(entries),
|
||||
STYLES,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES = {
|
||||
'background-color': '#222428',
|
||||
color: '#e0e0e0',
|
||||
'font-family': 'monospace',
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Directive, inject, Output } from '@angular/core'
|
||||
import { IntersectionObserveeService } from '@ng-web-apis/intersection-observer'
|
||||
import { convertAnsi, ErrorService } from '@start9labs/shared'
|
||||
import { catchError, defer, filter, from, map, of, switchMap, tap } from 'rxjs'
|
||||
import { LogsComponent } from './logs.component'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: '[logsFetch]',
|
||||
})
|
||||
export class LogsFetchDirective {
|
||||
private readonly observer = inject(IntersectionObserveeService)
|
||||
private readonly component = inject(LogsComponent)
|
||||
private readonly errors = inject(ErrorService)
|
||||
|
||||
@Output()
|
||||
readonly logsFetch = defer(() => this.observer).pipe(
|
||||
filter(([{ isIntersecting }]) => isIntersecting && !this.component.scroll),
|
||||
switchMap(() =>
|
||||
from(
|
||||
this.component.fetchLogs({
|
||||
cursor: this.component.startCursor,
|
||||
before: true,
|
||||
limit: 400,
|
||||
}),
|
||||
),
|
||||
),
|
||||
tap(res => this.component.setCursor(res.startCursor)),
|
||||
map(({ entries }) => convertAnsi(entries)),
|
||||
catchError(e => {
|
||||
this.errors.handleError(e)
|
||||
|
||||
return of('')
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<tui-scrollbar class="scrollbar">
|
||||
<section
|
||||
class="top"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="onLoading($event[0].isIntersecting)"
|
||||
(logsFetch)="onPrevious($event)"
|
||||
>
|
||||
@if (loading) {
|
||||
<tui-loader textContent="Loading older logs" />
|
||||
}
|
||||
</section>
|
||||
|
||||
<section #el childList (waMutationObserver)="restoreScroll(el)">
|
||||
@for (log of previous; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (followLogs | logs | async; as logs) {
|
||||
<section childList (waMutationObserver)="scrollToBottom()">
|
||||
@for (log of logs; track log) {
|
||||
<pre [innerHTML]="log | dompurify"></pre>
|
||||
}
|
||||
|
||||
@if ((status$ | async) !== 'connected') {
|
||||
<p class="loading-dots" [attr.data-status]="status$.value">
|
||||
{{
|
||||
status$.value === 'reconnecting'
|
||||
? 'Reconnecting'
|
||||
: 'Waiting for network connectivity'
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<tui-loader textContent="Loading logs" [style.margin-top.rem]="5" />
|
||||
}
|
||||
|
||||
<section
|
||||
#bottom
|
||||
class="bottom"
|
||||
waIntersectionObserver
|
||||
(waIntersectionObservee)="
|
||||
setScroll($event[$event.length - 1].isIntersecting)
|
||||
"
|
||||
></section>
|
||||
</tui-scrollbar>
|
||||
|
||||
<footer class="footer">
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconArrowDownCircle"
|
||||
(click)="setScroll(true); scrollToBottom()"
|
||||
>
|
||||
Scroll to bottom
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconDownload"
|
||||
[logsDownload]="fetchLogs"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</footer>
|
||||
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.top {
|
||||
height: 10rem;
|
||||
margin-bottom: -5rem;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--tui-clear);
|
||||
}
|
||||
|
||||
[data-status='reconnecting'] {
|
||||
color: var(--tui-success-fill);
|
||||
}
|
||||
|
||||
[data-status='disconnected'] {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
|
||||
import {
|
||||
INTERSECTION_ROOT,
|
||||
IntersectionObserverModule,
|
||||
} from '@ng-web-apis/intersection-observer'
|
||||
import { MutationObserverModule } from '@ng-web-apis/mutation-observer'
|
||||
import { FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
|
||||
import {
|
||||
TuiLoaderModule,
|
||||
TuiScrollbarComponent,
|
||||
TuiScrollbarModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { LogsDownloadDirective } from './logs-download.directive'
|
||||
import { LogsFetchDirective } from './logs-fetch.directive'
|
||||
import { LogsPipe } from './logs.pipe'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrls: ['./logs.component.scss'],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IntersectionObserverModule,
|
||||
MutationObserverModule,
|
||||
NgDompurifyModule,
|
||||
TuiButtonModule,
|
||||
TuiLoaderModule,
|
||||
TuiScrollbarModule,
|
||||
LogsDownloadDirective,
|
||||
LogsFetchDirective,
|
||||
LogsPipe,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: INTERSECTION_ROOT,
|
||||
useExisting: ElementRef,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class LogsComponent {
|
||||
@ViewChild('bottom')
|
||||
private readonly bottom?: ElementRef<HTMLElement>
|
||||
|
||||
@ViewChild(TuiScrollbarComponent, { read: ElementRef })
|
||||
private readonly scrollbar?: ElementRef<HTMLElement>
|
||||
|
||||
@Input({ required: true }) followLogs!: (
|
||||
params: RR.FollowServerLogsReq,
|
||||
) => Promise<RR.FollowServerLogsRes>
|
||||
|
||||
@Input({ required: true }) fetchLogs!: (
|
||||
params: FetchLogsReq,
|
||||
) => Promise<FetchLogsRes>
|
||||
|
||||
@Input({ required: true }) context!: string
|
||||
|
||||
scrollTop = 0
|
||||
startCursor?: string
|
||||
scroll = true
|
||||
loading = false
|
||||
previous: readonly string[] = []
|
||||
|
||||
readonly status$ = new BehaviorSubject<
|
||||
'connected' | 'disconnected' | 'reconnecting'
|
||||
>('connected')
|
||||
|
||||
onLoading(loading: boolean) {
|
||||
this.loading = loading && !this.scroll
|
||||
}
|
||||
|
||||
onPrevious(previous: string) {
|
||||
this.onLoading(false)
|
||||
this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0
|
||||
this.previous = [previous, ...this.previous]
|
||||
}
|
||||
|
||||
setCursor(startCursor = this.startCursor) {
|
||||
this.startCursor = startCursor
|
||||
}
|
||||
|
||||
setScroll(scroll: boolean) {
|
||||
this.scroll = scroll
|
||||
}
|
||||
|
||||
restoreScroll({ firstElementChild }: HTMLElement) {
|
||||
this.scrollbar?.nativeElement.scrollTo(
|
||||
this.scrollbar?.nativeElement.scrollLeft || 0,
|
||||
this.scrollTop + (firstElementChild?.clientHeight || 0),
|
||||
)
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
if (this.scroll)
|
||||
this.bottom?.nativeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||
import { convertAnsi, toLocalIsoString } from '@start9labs/shared'
|
||||
import {
|
||||
bufferTime,
|
||||
catchError,
|
||||
defer,
|
||||
filter,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
repeat,
|
||||
scan,
|
||||
skipWhile,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { LogsComponent } from './logs.component'
|
||||
|
||||
@Pipe({
|
||||
name: 'logs',
|
||||
standalone: true,
|
||||
})
|
||||
export class LogsPipe implements PipeTransform {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly logs = inject(LogsComponent)
|
||||
private readonly connection = inject(ConnectionService)
|
||||
|
||||
transform(
|
||||
followLogs: (
|
||||
params: RR.FollowServerLogsReq,
|
||||
) => Promise<RR.FollowServerLogsRes>,
|
||||
): Observable<readonly string[]> {
|
||||
return merge(
|
||||
this.logs.status$.pipe(
|
||||
skipWhile(value => value === 'connected'),
|
||||
filter(value => value === 'connected'),
|
||||
map(() => getMessage(true)),
|
||||
),
|
||||
defer(() => followLogs(this.options)).pipe(
|
||||
tap(r => this.logs.setCursor(r.startCursor)),
|
||||
switchMap(r => this.api.openLogsWebsocket$(this.toConfig(r.guid))),
|
||||
bufferTime(1000),
|
||||
filter(logs => !!logs.length),
|
||||
map(convertAnsi),
|
||||
),
|
||||
).pipe(
|
||||
catchError(() =>
|
||||
this.connection.connected$.pipe(
|
||||
tap(v => this.logs.status$.next(v ? 'reconnecting' : 'disconnected')),
|
||||
filter(Boolean),
|
||||
take(1),
|
||||
ignoreElements(),
|
||||
startWith(getMessage(false)),
|
||||
),
|
||||
),
|
||||
repeat(),
|
||||
scan((logs: string[], log) => [...logs, log], []),
|
||||
)
|
||||
}
|
||||
|
||||
private get options() {
|
||||
return this.logs.status$.value === 'connected' ? { limit: 400 } : {}
|
||||
}
|
||||
|
||||
private toConfig(guid: string) {
|
||||
return {
|
||||
url: `/rpc/${guid}`,
|
||||
openObserver: {
|
||||
next: () => this.logs.status$.next('connected'),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMessage(success: boolean): string {
|
||||
return `<p style="color: ${
|
||||
success ? 'var(--tui-success-fill)' : 'var(--tui-error-fill)'
|
||||
}; text-align: center;">${
|
||||
success ? 'Reconnected' : 'Disconnected'
|
||||
} at ${toLocalIsoString(new Date())}</p>`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router'
|
||||
import { TuiTabBarModule } from '@taiga-ui/addon-mobile'
|
||||
import { combineLatest, map, startWith } from 'rxjs'
|
||||
import { SYSTEM_UTILITIES } from 'src/app/utils/system-utilities'
|
||||
import { BadgeService } from 'src/app/services/badge.service'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-tabs',
|
||||
template: `
|
||||
<nav tuiTabBar>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconGrid"
|
||||
routerLink="/portal/dashboard"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
>
|
||||
Services
|
||||
</a>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconActivity"
|
||||
routerLink="/portal/dashboard"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[queryParams]="{ tab: 'metrics' }"
|
||||
>
|
||||
Metrics
|
||||
</a>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconSettings"
|
||||
routerLink="/portal/dashboard"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[queryParams]="{ tab: 'utilities' }"
|
||||
[badge]="(utils$ | async) || 0"
|
||||
>
|
||||
Utilities
|
||||
</a>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
routerLinkActive
|
||||
routerLink="/portal/system/notifications"
|
||||
icon="tuiIconBell"
|
||||
[badge]="(notification$ | async) || 0"
|
||||
>
|
||||
Notifications
|
||||
</a>
|
||||
</nav>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: none;
|
||||
// TODO: Theme
|
||||
--tui-elevation-01: #333;
|
||||
--tui-base-04: var(--tui-clear);
|
||||
backdrop-filter: blur(1rem);
|
||||
}
|
||||
|
||||
[tuiTabBar]::before {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
imports: [AsyncPipe, RouterLink, RouterLinkActive, TuiTabBarModule],
|
||||
})
|
||||
export class TabsComponent {
|
||||
private readonly badge = inject(BadgeService)
|
||||
|
||||
readonly utils$ = combineLatest(
|
||||
Object.keys(SYSTEM_UTILITIES)
|
||||
.filter(key => key !== '/portal/system/notifications')
|
||||
.map(key => this.badge.getCount(key).pipe(startWith(0))),
|
||||
).pipe(map(values => values.reduce((acc, value) => acc + value, 0)))
|
||||
readonly notification$ = inject(NotificationService).unreadCount$
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
280
web/projects/ui/src/app/routes/portal/modals/config.component.ts
Normal file
280
web/projects/ui/src/app/routes/portal/modals/config.component.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { CT } 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 { ConfigDepComponent } from 'src/app/routes/portal/modals/config-dep.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { hasCurrentDeps } from 'src/app/utils/has-deps'
|
||||
import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
getPackage,
|
||||
} from 'src/app/utils/get-package-data'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { InvalidService } from 'src/app/routes/portal/components/form/invalid.service'
|
||||
import {
|
||||
ActionButton,
|
||||
FormComponent,
|
||||
} from 'src/app/routes/portal/components/form.component'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import { ToManifestPipe } from '../pipes/to-manifest'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader *ngIf="loadingText" size="l" [textContent]="loadingText" />
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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,
|
||||
ToManifestPipe,
|
||||
],
|
||||
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 = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
await this.uploadFiles(config, loader)
|
||||
|
||||
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 uploadFiles(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
// TODO: Could be nested files
|
||||
const keys = Object.keys(config).filter(key => config[key] instanceof File)
|
||||
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
|
||||
|
||||
if (!keys.length) return
|
||||
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
|
||||
const hashes = await Promise.all(
|
||||
keys.map(key => this.embassyApi.uploadFile(config[key])),
|
||||
)
|
||||
keys.forEach((key, i) => (config[key] = hashes[i]))
|
||||
}
|
||||
|
||||
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: Breakages): 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}${Object.keys(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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
123
web/projects/ui/src/app/routes/portal/modals/prompt.component.ts
Normal file
123
web/projects/ui/src/app/routes/portal/modals/prompt.component.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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
|
||||
}
|
||||
16
web/projects/ui/src/app/routes/portal/modals/qr.component.ts
Normal file
16
web/projects/ui/src/app/routes/portal/modals/qr.component.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'qr',
|
||||
template: '<qr-code [value]="context.data" size="400"></qr-code>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [QrCodeModule],
|
||||
})
|
||||
export class QRModal {
|
||||
readonly context =
|
||||
inject<TuiDialogContext<void, string>>(POLYMORPHEUS_CONTEXT)
|
||||
}
|
||||
13
web/projects/ui/src/app/routes/portal/pipes/to-manifest.ts
Normal file
13
web/projects/ui/src/app/routes/portal/pipes/to-manifest.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
@Pipe({
|
||||
name: 'toManifest',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToManifestPipe implements PipeTransform {
|
||||
transform(pkg: PackageDataEntry) {
|
||||
return getManifest(pkg)
|
||||
}
|
||||
}
|
||||
70
web/projects/ui/src/app/routes/portal/portal.component.ts
Normal file
70
web/projects/ui/src/app/routes/portal/portal.component.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
Router,
|
||||
RouterOutlet,
|
||||
} from '@angular/router'
|
||||
import { TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
|
||||
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { HeaderComponent } from './components/header/header.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<header appHeader>{{ name$ | async }}</header>
|
||||
<main [attr.data-dashboard]="tab$ | async">
|
||||
<tui-scrollbar [style.max-height.%]="100">
|
||||
<router-outlet />
|
||||
</tui-scrollbar>
|
||||
</main>
|
||||
<app-tabs />
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// TODO: Theme
|
||||
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
HeaderComponent,
|
||||
TabsComponent,
|
||||
TuiScrollbarModule,
|
||||
],
|
||||
})
|
||||
export class PortalComponent {
|
||||
private readonly breadcrumbs = inject(BreadcrumbsService)
|
||||
private readonly _ = inject(Router)
|
||||
.events.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(e => {
|
||||
this.breadcrumbs.update(e.url.replace('/portal/service/', ''))
|
||||
})
|
||||
|
||||
readonly name$ = inject(PatchDB<DataModel>).watch$('ui', 'name')
|
||||
readonly tab$ = inject(ActivatedRoute).queryParams.pipe(
|
||||
map(params => params['tab']),
|
||||
)
|
||||
}
|
||||
35
web/projects/ui/src/app/routes/portal/portal.routes.ts
Normal file
35
web/projects/ui/src/app/routes/portal/portal.routes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { PortalComponent } from './portal.component'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PortalComponent,
|
||||
children: [
|
||||
{
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full',
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () =>
|
||||
import('./routes/dashboard/dashboard.component').then(
|
||||
m => m.DashboardComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'service',
|
||||
loadChildren: () =>
|
||||
import('./routes/service/service.module').then(m => m.ServiceModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
loadChildren: () =>
|
||||
import('./routes/system/system.module').then(m => m.SystemModule),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default ROUTES
|
||||
@@ -0,0 +1,95 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
tuiButtonOptionsProvider,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { map } from 'rxjs'
|
||||
import { UILaunchComponent } from 'src/app/routes/portal/routes/dashboard/ui.component'
|
||||
import { ActionsService } from 'src/app/services/actions.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'fieldset[appControls]',
|
||||
template: `
|
||||
@if (pkg().status.main.status === 'running') {
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconSquare"
|
||||
(click)="actions.stop(manifest())"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconRotateCw"
|
||||
(click)="actions.restart(manifest())"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
*tuiLet="hasUnmet() | async as hasUnmet"
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconPlay"
|
||||
[disabled]="!pkg().status.configured"
|
||||
(click)="actions.start(manifest(), !!hasUnmet)"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconTool"
|
||||
(click)="actions.configure(manifest())"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
}
|
||||
|
||||
<app-ui-launch [pkg]="pkg()" />
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButtonModule, UILaunchComponent, TuiLetModule, AsyncPipe],
|
||||
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
|
||||
styles: `
|
||||
:host {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ControlsComponent {
|
||||
private readonly errors = inject(DepErrorService)
|
||||
readonly actions = inject(ActionsService)
|
||||
|
||||
pkg = input.required<PackageDataEntry>()
|
||||
|
||||
readonly manifest = computed(() => getManifest(this.pkg()))
|
||||
readonly hasUnmet = computed(() =>
|
||||
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
|
||||
map(errors =>
|
||||
Object.keys(this.pkg().currentDependencies)
|
||||
.map(id => !!(errors[id] as any)?.[id]) // @TODO fix type
|
||||
.some(Boolean),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { map, timer } from 'rxjs'
|
||||
import { MetricsComponent } from './metrics.component'
|
||||
import { ServicesComponent } from './services.component'
|
||||
import { UtilitiesComponent } from './utilities.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<time>{{ date() | date: 'medium' }}</time>
|
||||
<app-metrics>
|
||||
<h2>
|
||||
<tui-icon icon="tuiIconActivity" />
|
||||
Metrics
|
||||
</h2>
|
||||
<div class="g-plaque"></div>
|
||||
</app-metrics>
|
||||
<app-utilities>
|
||||
<h2>
|
||||
<tui-icon icon="tuiIconSettings" />
|
||||
Utilities
|
||||
</h2>
|
||||
<div class="g-plaque"></div>
|
||||
</app-utilities>
|
||||
<app-services>
|
||||
<h2>
|
||||
<tui-icon icon="tuiIconGrid" />
|
||||
Services
|
||||
</h2>
|
||||
<div class="g-plaque"></div>
|
||||
</app-services>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
height: calc(100vh - 6rem);
|
||||
position: relative;
|
||||
max-width: 64rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin: 2rem auto 0;
|
||||
border: 0.375rem solid transparent;
|
||||
}
|
||||
|
||||
app-metrics,
|
||||
app-utilities,
|
||||
app-services {
|
||||
position: relative;
|
||||
clip-path: var(--clip-path);
|
||||
backdrop-filter: blur(1rem);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
time {
|
||||
position: absolute;
|
||||
left: 22%;
|
||||
font-weight: bold;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0 2rem;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
height: calc(100vh - 7rem);
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
|
||||
app-metrics,
|
||||
app-utilities,
|
||||
app-services {
|
||||
display: none;
|
||||
}
|
||||
|
||||
time,
|
||||
h2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile [data-dashboard='metrics']) {
|
||||
app-metrics {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile [data-dashboard='utilities']) {
|
||||
app-utilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile main:not([data-dashboard])) {
|
||||
app-services {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
ServicesComponent,
|
||||
MetricsComponent,
|
||||
UtilitiesComponent,
|
||||
TuiIconModule,
|
||||
DatePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent {
|
||||
readonly date = toSignal(timer(0, 1000).pipe(map(() => new Date())))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user