mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
rename frontend to web and update contributing guide (#2509)
* rename frontend to web and update contributing guide * rename this time * fix build * restructure rust code * update documentation * update descriptions * Update CONTRIBUTING.md Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com> --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ActionSuccessPage } from './action-success.page'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ActionSuccessPage],
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
exports: [ActionSuccessPage],
|
||||
})
|
||||
export class ActionSuccessPageModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Execution Complete</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()" class="enter-click">
|
||||
<ion-icon name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<h2 class="ion-padding">{{ actionRes.message }}</h2>
|
||||
|
||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
|
||||
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
||||
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
|
||||
<a
|
||||
*ngIf="actionRes.copyable"
|
||||
style="cursor: copy"
|
||||
(click)="copy(actionRes.value)"
|
||||
>
|
||||
<b>{{ actionRes.value }}</b>
|
||||
<sup>
|
||||
<ion-icon
|
||||
name="copy-outline"
|
||||
style="padding-left: 6px; font-size: small"
|
||||
></ion-icon>
|
||||
</sup>
|
||||
</a>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
import { copyToClipboard } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'action-success',
|
||||
templateUrl: './action-success.page.html',
|
||||
styleUrls: ['./action-success.page.scss'],
|
||||
})
|
||||
export class ActionSuccessPage {
|
||||
@Input()
|
||||
actionRes!: ActionResponse
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
) {}
|
||||
|
||||
async copy(address: string) {
|
||||
let message = ''
|
||||
await copyToClipboard(address || '').then(success => {
|
||||
message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigPage } from './app-config.page'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
TextSpinnerComponentModule,
|
||||
FormObjectComponentModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
exports: [AppConfigPage],
|
||||
})
|
||||
export class AppConfigPageModule {}
|
||||
149
web/projects/ui/src/app/modals/app-config/app-config.page.html
Normal file
149
web/projects/ui/src/app/modals/app-config/app-config.page.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Config</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading; else notLoading"
|
||||
[text]="loadingText"
|
||||
></text-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #notLoading>
|
||||
<ion-item *ngIf="loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">{{ loadingError }}</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<ng-container *ngIf="configForm && !pkg.installed?.status?.configured">
|
||||
<ng-container *ngIf="!original; else hasOriginal">
|
||||
<h2
|
||||
*ngIf="!configForm.dirty"
|
||||
class="ion-padding-bottom header-details"
|
||||
>
|
||||
<ion-text color="success">
|
||||
{{ pkg.manifest.title }} has been automatically configured with
|
||||
recommended defaults. Make whatever changes you want, then click
|
||||
"Save".
|
||||
</ion-text>
|
||||
</h2>
|
||||
</ng-container>
|
||||
<ng-template #hasOriginal>
|
||||
<h2 *ngIf="hasNewOptions" class="ion-padding-bottom header-details">
|
||||
<ion-text color="success">
|
||||
New config options! To accept the default values, click "Save".
|
||||
You may also customize these new options below.
|
||||
</ion-text>
|
||||
</h2>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<!-- auto-config -->
|
||||
<ion-item
|
||||
lines="none"
|
||||
*ngIf="dependentInfo"
|
||||
class="rec-item"
|
||||
style="margin-bottom: 48px"
|
||||
>
|
||||
<ion-label>
|
||||
<h2 style="display: flex; align-items: center">
|
||||
<img
|
||||
style="width: 18px; margin: 4px"
|
||||
[src]="pkg['static-files'].icon"
|
||||
[alt]="pkg.manifest.title"
|
||||
/>
|
||||
<ion-text
|
||||
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
|
||||
>
|
||||
{{ pkg.manifest.title }}
|
||||
</ion-text>
|
||||
</h2>
|
||||
<p>
|
||||
<ion-text color="dark">
|
||||
The following modifications have been made to {{
|
||||
pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHtml]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- no options -->
|
||||
<ion-item *ngIf="!hasOptions">
|
||||
<ion-label>
|
||||
<p>
|
||||
No config options for {{ pkg.manifest.title }} {{
|
||||
pkg.manifest.version }}.
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- has config -->
|
||||
<form
|
||||
*ngIf="configForm && configSpec"
|
||||
[formGroup]="configForm"
|
||||
novalidate
|
||||
>
|
||||
<form-object
|
||||
[objectSpec]="configSpec"
|
||||
[formGroup]="configForm"
|
||||
[current]="configForm.value"
|
||||
[original]="original"
|
||||
(hasNewOptions)="hasNewOptions = true"
|
||||
></form-object>
|
||||
</form>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ng-container *ngIf="!loading && !loadingError">
|
||||
<ion-buttons
|
||||
*ngIf="configForm && hasOptions"
|
||||
slot="start"
|
||||
class="ion-padding-start"
|
||||
>
|
||||
<ion-button fill="clear" (click)="resetDefaults()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Reset Defaults
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
*ngIf="configForm"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="saving"
|
||||
(click)="tryConfigure()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save
|
||||
</ion-button>
|
||||
<ion-button
|
||||
*ngIf="!configForm"
|
||||
fill="solid"
|
||||
color="dark"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Close
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ng-container>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,12 @@
|
||||
.notifier-item {
|
||||
margin: 12px;
|
||||
margin-top: 0px;
|
||||
border-radius: 12px;
|
||||
// kills the lines
|
||||
--border-width: 0;
|
||||
--inner-border-width: 0;
|
||||
}
|
||||
|
||||
.header-details {
|
||||
font-size: 20px;
|
||||
}
|
||||
344
web/projects/ui/src/app/modals/app-config/app-config.page.ts
Normal file
344
web/projects/ui/src/app/modals/app-config/app-config.page.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
ModalController,
|
||||
LoadingController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
ErrorToastService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
isObject,
|
||||
} from '@start9labs/shared'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { UntypedFormGroup } from '@angular/forms'
|
||||
import {
|
||||
convertValuesRecursive,
|
||||
FormService,
|
||||
} from 'src/app/services/form.service'
|
||||
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { getAllPackages, getPackage } from 'src/app/util/get-package-data'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config',
|
||||
templateUrl: './app-config.page.html',
|
||||
styleUrls: ['./app-config.page.scss'],
|
||||
})
|
||||
export class AppConfigPage {
|
||||
@Input() pkgId!: string
|
||||
|
||||
@Input() dependentInfo?: DependentInfo
|
||||
|
||||
pkg!: PackageDataEntry
|
||||
loadingText = ''
|
||||
|
||||
configSpec?: ConfigSpec
|
||||
configForm?: UntypedFormGroup
|
||||
|
||||
original?: object // only if existing config
|
||||
diff?: string[] // only if dependent info
|
||||
|
||||
loading = true
|
||||
hasNewOptions = false
|
||||
saving = false
|
||||
loadingError: string | IonicSafeString = ''
|
||||
|
||||
hasOptions = false
|
||||
|
||||
constructor(
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const pkg = await getPackage(this.patch, this.pkgId)
|
||||
if (!pkg) return
|
||||
this.pkg = pkg
|
||||
|
||||
if (!this.pkg.manifest.config) return
|
||||
|
||||
let newConfig: object | undefined
|
||||
let patch: Operation[] | undefined
|
||||
|
||||
if (this.dependentInfo) {
|
||||
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
const {
|
||||
'old-config': oc,
|
||||
'new-config': nc,
|
||||
spec: s,
|
||||
} = await this.embassyApi.dryConfigureDependency({
|
||||
'dependency-id': this.pkgId,
|
||||
'dependent-id': this.dependentInfo.id,
|
||||
})
|
||||
this.original = oc
|
||||
newConfig = nc
|
||||
this.configSpec = s
|
||||
patch = compare(this.original, newConfig)
|
||||
} else {
|
||||
this.loadingText = 'Loading Config'
|
||||
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({
|
||||
id: this.pkgId,
|
||||
})
|
||||
this.original = c
|
||||
this.configSpec = s
|
||||
}
|
||||
|
||||
this.configForm = this.formService.createForm(
|
||||
this.configSpec,
|
||||
newConfig || this.original,
|
||||
)
|
||||
|
||||
this.hasOptions = !!Object.values(this.configSpec).find(
|
||||
valSpec => valSpec.type !== 'pointer',
|
||||
)
|
||||
|
||||
if (patch) {
|
||||
this.diff = this.getDiff(patch)
|
||||
this.markDirty(patch)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
resetDefaults() {
|
||||
this.configForm = this.formService.createForm(this.configSpec!)
|
||||
const patch = compare(this.original || {}, this.configForm.value)
|
||||
this.markDirty(patch)
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
if (this.configForm?.dirty) {
|
||||
this.presentAlertUnsaved()
|
||||
} else {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async tryConfigure() {
|
||||
convertValuesRecursive(this.configSpec!, this.configForm!)
|
||||
|
||||
if (this.configForm!.invalid) {
|
||||
document
|
||||
.getElementsByClassName('validation-error')[0]
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
|
||||
if (hasCurrentDeps(this.pkg)) {
|
||||
this.dryConfigure()
|
||||
} else {
|
||||
this.configure()
|
||||
}
|
||||
}
|
||||
|
||||
private async dryConfigure() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Checking dependent services...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config: this.configForm!.value,
|
||||
})
|
||||
|
||||
if (isEmptyObject(breakages)) {
|
||||
this.configure(loader)
|
||||
} else {
|
||||
await loader.dismiss()
|
||||
const proceed = await this.presentAlertBreakages(breakages)
|
||||
if (proceed) {
|
||||
this.configure()
|
||||
} else {
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.saving = false
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(loader?: HTMLIonLoadingElement) {
|
||||
const message = 'Saving...'
|
||||
if (loader) {
|
||||
loader.message = message
|
||||
} else {
|
||||
loader = await this.loadingCtrl.create({ message })
|
||||
await loader.present()
|
||||
}
|
||||
|
||||
try {
|
||||
await this.embassyApi.setPackageConfig({
|
||||
id: this.pkgId,
|
||||
config: this.configForm!.value,
|
||||
})
|
||||
this.modalCtrl.dismiss()
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.saving = false
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
|
||||
let message: string =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const localPkgs = await getAllPackages(this.patch)
|
||||
const bullets = Object.keys(breakages).map(id => {
|
||||
const title = localPkgs[id].manifest.title
|
||||
return `<li><b>${title}</b></li>`
|
||||
})
|
||||
message = `${message}${bullets}</ul>`
|
||||
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
}
|
||||
|
||||
private getDiff(patch: Operation[]): string[] {
|
||||
return patch.map(op => {
|
||||
let message: string
|
||||
switch (op.op) {
|
||||
case 'add':
|
||||
message = `Added ${this.getNewValue(op.value)}`
|
||||
break
|
||||
case 'remove':
|
||||
message = `Removed ${this.getOldValue(op.path)}`
|
||||
break
|
||||
case 'replace':
|
||||
message = `Changed from ${this.getOldValue(
|
||||
op.path,
|
||||
)} to ${this.getNewValue(op.value)}`
|
||||
break
|
||||
default:
|
||||
message = `Unknown operation`
|
||||
}
|
||||
|
||||
let displayPath: string
|
||||
|
||||
const arrPath = op.path
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
||||
arrPath.pop()
|
||||
}
|
||||
|
||||
displayPath = arrPath.join(' → ')
|
||||
|
||||
return `${displayPath}: ${message}`
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
private markDirty(patch: Operation[]) {
|
||||
patch.forEach(op => {
|
||||
const arrPath = op.path
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
|
||||
|
||||
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
||||
const prevPath = arrPath.slice(0, arrPath.length - 1)
|
||||
this.configForm!.get(prevPath)?.markAsDirty()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async presentAlertUnsaved() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Unsaved Changes',
|
||||
message: 'You have unsaved changes. Are you sure you want to leave?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: `Leave`,
|
||||
handler: () => {
|
||||
this.modalCtrl.dismiss()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
import { AppRecoverSelectPage } from './app-recover-select.page'
|
||||
import { ToOptionsPipe } from './to-options.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppRecoverSelectPage, ToOptionsPipe],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [AppRecoverSelectPage],
|
||||
})
|
||||
export class AppRecoverSelectPageModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
<ng-container
|
||||
*ngIf="packageData$ | toOptions : backupInfo['package-backups'] | async as options"
|
||||
>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Restore</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let option of options">
|
||||
<ion-label>
|
||||
<h2>{{ option.title }}</h2>
|
||||
<p>Version {{ option.version }}</p>
|
||||
<p>Backup made: {{ option.timestamp | date : 'medium' }}</p>
|
||||
<p *ngIf="!option.installed && !option['newer-eos']">
|
||||
<ion-text color="success">Ready to restore</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option.installed">
|
||||
<ion-text color="warning">
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option['newer-eos']">
|
||||
<ion-text color="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.checked"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
(ionChange)="handleChange(options)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
class="enter-click btn-128"
|
||||
[disabled]="!hasSelection"
|
||||
(click)="restore(options)"
|
||||
>
|
||||
Restore Selected
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
import { getErrorMessage } from '@start9labs/shared'
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AppRecoverOption } from './to-options.pipe'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover-select',
|
||||
templateUrl: './app-recover-select.page.html',
|
||||
styleUrls: ['./app-recover-select.page.scss'],
|
||||
})
|
||||
export class AppRecoverSelectPage {
|
||||
@Input() id!: string
|
||||
@Input() backupInfo!: BackupInfo
|
||||
@Input() password!: string
|
||||
@Input() oldPassword?: string
|
||||
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
|
||||
hasSelection = false
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
handleChange(options: AppRecoverOption[]) {
|
||||
this.hasSelection = options.some(o => o.checked)
|
||||
}
|
||||
|
||||
async restore(options: AppRecoverOption[]): Promise<void> {
|
||||
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Initializing...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.id,
|
||||
'old-password': this.oldPassword || null,
|
||||
password: this.password,
|
||||
})
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
export interface AppRecoverOption extends PackageBackupInfo {
|
||||
id: string
|
||||
checked: boolean
|
||||
installed: boolean
|
||||
'newer-eos': boolean
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toOptions',
|
||||
})
|
||||
export class ToOptionsPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly emver: Emver,
|
||||
) {}
|
||||
|
||||
transform(
|
||||
packageData$: Observable<Record<string, PackageDataEntry>>,
|
||||
packageBackups: Record<string, PackageBackupInfo> = {},
|
||||
): Observable<AppRecoverOption[]> {
|
||||
return packageData$.pipe(
|
||||
map(packageData =>
|
||||
Object.keys(packageBackups)
|
||||
.map(id => ({
|
||||
...packageBackups[id],
|
||||
id,
|
||||
installed: !!packageData[id],
|
||||
checked: false,
|
||||
'newer-eos': this.compare(packageBackups[id]['os-version']),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private compare(version: string): boolean {
|
||||
// checks to see if backup was made on a newer version of eOS
|
||||
return this.emver.compare(version, this.config.version) === 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupReportPage } from './backup-report.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupReportPage],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [BackupReportPage],
|
||||
})
|
||||
export class BackupReportPageModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Backup Report</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Completed: {{ timestamp | date : 'medium' }}
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>System data</h2>
|
||||
<p><ion-text [color]="system.color">{{ system.result }}</ion-text></p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="system.icon"
|
||||
[color]="system.color"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item *ngFor="let pkg of report?.packages | keyvalue">
|
||||
<ion-label>
|
||||
<h2>{{ pkg.key }}</h2>
|
||||
<p>
|
||||
<ion-text [color]="pkg.value.error ? 'danger' : 'success'">
|
||||
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="pkg.value.error ? 'remove-circle-outline' : 'checkmark'"
|
||||
[color]="pkg.value.error ? 'danger' : 'success'"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { BackupReport } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-report',
|
||||
templateUrl: './backup-report.page.html',
|
||||
styleUrls: ['./backup-report.page.scss'],
|
||||
})
|
||||
export class BackupReportPage {
|
||||
@Input() report!: BackupReport
|
||||
@Input() timestamp!: string
|
||||
|
||||
system!: {
|
||||
result: string
|
||||
icon: 'remove' | 'remove-circle-outline' | 'checkmark'
|
||||
color: 'dark' | 'danger' | 'success'
|
||||
}
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (!this.report.server.attempted) {
|
||||
this.system = {
|
||||
result: 'Not Attempted',
|
||||
icon: 'remove',
|
||||
color: 'dark',
|
||||
}
|
||||
} else if (this.report.server.error) {
|
||||
this.system = {
|
||||
result: `Failed: ${this.report.server.error}`,
|
||||
icon: 'remove-circle-outline',
|
||||
color: 'danger',
|
||||
}
|
||||
} else {
|
||||
this.system = {
|
||||
result: 'Succeeded',
|
||||
icon: 'checkmark',
|
||||
color: 'success',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupSelectPage } from './backup-select.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupSelectPage],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [BackupSelectPage],
|
||||
})
|
||||
export class BackupSelectPageModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Back Up</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ng-container *ngIf="pkgs.length; else empty">
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-buttons slot="end" style="padding-bottom: 6px">
|
||||
<ion-button fill="clear" (click)="toggleSelectAll()">
|
||||
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let pkg of pkgs">
|
||||
<ion-avatar slot="start">
|
||||
<img alt="" [src]="pkg.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ pkg.title }}</h2>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="pkg.checked"
|
||||
(ionChange)="handleChange()"
|
||||
[disabled]="pkg.disabled"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<ng-template #empty>
|
||||
<h2 class="center">No services installed!</h2>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
[disabled]="!hasSelection"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss(true)"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Back Up Selected
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,5 @@
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { map, take } from 'rxjs/operators'
|
||||
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-select',
|
||||
templateUrl: './backup-select.page.html',
|
||||
styleUrls: ['./backup-select.page.scss'],
|
||||
})
|
||||
export class BackupSelectPage {
|
||||
hasSelection = false
|
||||
selectAll = false
|
||||
pkgs: {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
disabled: boolean
|
||||
checked: boolean
|
||||
}[] = []
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.pkgs = await firstValueFrom(
|
||||
this.patch.watch$('package-data').pipe(
|
||||
map(pkgs => {
|
||||
return Object.values(pkgs)
|
||||
.map(pkg => {
|
||||
const { id, title } = pkg.manifest
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
icon: pkg['static-files'].icon,
|
||||
disabled: pkg.state !== PackageState.Installed,
|
||||
checked: pkg.state === PackageState.Installed,
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
dismiss(success = false) {
|
||||
if (success) {
|
||||
const ids = this.pkgs.filter(p => p.checked).map(p => p.id)
|
||||
this.modalCtrl.dismiss(ids)
|
||||
} else {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
this.hasSelection = this.pkgs.some(p => p.checked)
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
this.pkgs.forEach(pkg => (pkg.checked = this.selectAll))
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/modals/enum-list/enum-list.module.ts
Normal file
12
web/projects/ui/src/app/modals/enum-list/enum-list.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EnumListPage } from './enum-list.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [EnumListPage],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [EnumListPage],
|
||||
})
|
||||
export class EnumListPageModule {}
|
||||
45
web/projects/ui/src/app/modals/enum-list/enum-list.page.html
Normal file
45
web/projects/ui/src/app/modals/enum-list/enum-list.page.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item-divider style="padding-bottom: 6px">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button fill="clear" (click)="toggleSelectAll()">
|
||||
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let option of options | keyvalue : asIsOrder">
|
||||
<ion-label>{{ spec.spec['value-names'][option.key] }}</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.value"
|
||||
(click)="toggleSelected(option.key)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Done
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
50
web/projects/ui/src/app/modals/enum-list/enum-list.page.ts
Normal file
50
web/projects/ui/src/app/modals/enum-list/enum-list.page.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ValueSpecListOf } from 'src/app/pkg-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'enum-list',
|
||||
templateUrl: './enum-list.page.html',
|
||||
styleUrls: ['./enum-list.page.scss'],
|
||||
})
|
||||
export class EnumListPage {
|
||||
@Input() key!: string
|
||||
@Input() spec!: ValueSpecListOf<'enum'>
|
||||
@Input() current: string[] = []
|
||||
|
||||
options: { [option: string]: boolean } = {}
|
||||
selectAll = false
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
for (let val of this.spec.spec.values || []) {
|
||||
this.options[val] = this.current.includes(val)
|
||||
}
|
||||
// if none are selected, set selectAll to true
|
||||
this.selectAll = Object.values(this.options).some(k => !k)
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
save() {
|
||||
this.modalCtrl.dismiss(
|
||||
Object.keys(this.options).filter(key => this.options[key]),
|
||||
)
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
Object.keys(this.options).forEach(k => (this.options[k] = this.selectAll))
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
|
||||
toggleSelected(key: string) {
|
||||
this.options[key] = !this.options[key]
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { GenericFormPage } from './generic-form.page'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericFormPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormObjectComponentModule,
|
||||
],
|
||||
exports: [GenericFormPage],
|
||||
})
|
||||
export class GenericFormPageModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form
|
||||
[formGroup]="formGroup"
|
||||
(ngSubmit)="handleClick(submitBtn.handler)"
|
||||
novalidate
|
||||
>
|
||||
<form-object [objectSpec]="spec" [formGroup]="formGroup"></form-object>
|
||||
<button hidden type="submit"></button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar class="footer">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
*ngFor="let button of buttons"
|
||||
(click)="handleClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,9 @@
|
||||
button:disabled,
|
||||
button[disabled]{
|
||||
border: 1px solid #999999;
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
}
|
||||
button {
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { UntypedFormGroup } from '@angular/forms'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import {
|
||||
convertValuesRecursive,
|
||||
FormService,
|
||||
} from 'src/app/services/form.service'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
|
||||
export interface ActionButton {
|
||||
text: string
|
||||
handler: (value: any) => Promise<boolean>
|
||||
isSubmit?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'generic-form',
|
||||
templateUrl: './generic-form.page.html',
|
||||
styleUrls: ['./generic-form.page.scss'],
|
||||
})
|
||||
export class GenericFormPage {
|
||||
@Input() title!: string
|
||||
@Input() spec!: ConfigSpec
|
||||
@Input() buttons!: ActionButton[]
|
||||
@Input() initialValue: object = {}
|
||||
|
||||
submitBtn!: ActionButton
|
||||
formGroup!: UntypedFormGroup
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.formGroup = this.formService.createForm(this.spec, this.initialValue)
|
||||
this.submitBtn = this.buttons.find(btn => btn.isSubmit) || {
|
||||
text: '',
|
||||
handler: () => Promise.resolve(true),
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async handleClick(handler: ActionButton['handler']): Promise<void> {
|
||||
convertValuesRecursive(this.spec, this.formGroup)
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
document
|
||||
.getElementsByClassName('validation-error')[0]
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
// @TODO make this more like generic input component dismissal
|
||||
const success = await handler(this.formGroup.value)
|
||||
if (success !== false) this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-content>
|
||||
<div
|
||||
style="margin: 24px 24px 12px 24px; display: flex; flex-direction: column"
|
||||
>
|
||||
<ion-item style="padding-bottom: 8px">
|
||||
<ion-label>
|
||||
<h1>{{ options.title }}</h1>
|
||||
<br />
|
||||
<p>{{ options.message }}</p>
|
||||
<ng-container *ngIf="options.warning">
|
||||
<br />
|
||||
<p>
|
||||
<ion-text color="warning">{{ options.warning }}</ion-text>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<form (ngSubmit)="submit()">
|
||||
<div style="margin: 0 0 24px 16px">
|
||||
<p class="input-label">{{ options.label }}</p>
|
||||
<ion-item
|
||||
lines="none"
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
>
|
||||
<ion-input
|
||||
#mainInput
|
||||
type="text"
|
||||
name="value"
|
||||
[ngModel]="masked ? maskedValue : value"
|
||||
(ngModelChange)="transformInput($event)"
|
||||
[placeholder]="options.placeholder"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
*ngIf="options.useMask"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="toggleMask()"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
[name]="!masked ? 'eye-off-outline' : 'eye-outline'"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<!-- error -->
|
||||
<p *ngIf="error">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ion-text-right">
|
||||
<ion-button fill="clear" (click)="cancel()">Cancel</ion-button>
|
||||
<ion-button
|
||||
fill="clear"
|
||||
type="submit"
|
||||
[disabled]="!value && !options.nullable"
|
||||
>
|
||||
{{ options.buttonText }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { GenericInputComponent } from './generic-input.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericInputComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild([]),
|
||||
SharedPipesModule,
|
||||
],
|
||||
exports: [GenericInputComponent],
|
||||
})
|
||||
export class GenericInputComponentModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, inject, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
|
||||
import { getErrorMessage, THEME } from '@start9labs/shared'
|
||||
import { MaskPipe } from 'src/app/pipes/mask/mask.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'generic-input',
|
||||
templateUrl: './generic-input.component.html',
|
||||
styleUrls: ['./generic-input.component.scss'],
|
||||
providers: [MaskPipe],
|
||||
})
|
||||
export class GenericInputComponent {
|
||||
@ViewChild('mainInput') elem?: IonInput
|
||||
|
||||
@Input() options!: GenericInputOptions
|
||||
|
||||
value!: string
|
||||
masked!: boolean
|
||||
|
||||
maskedValue?: string
|
||||
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly mask: MaskPipe,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const defaultOptions: Partial<GenericInputOptions> = {
|
||||
buttonText: 'Submit',
|
||||
placeholder: 'Enter value',
|
||||
nullable: false,
|
||||
useMask: false,
|
||||
initialValue: '',
|
||||
}
|
||||
this.options = {
|
||||
...defaultOptions,
|
||||
...this.options,
|
||||
}
|
||||
|
||||
this.masked = !!this.options.useMask
|
||||
this.value = this.options.initialValue || ''
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.elem?.setFocus(), 400)
|
||||
}
|
||||
|
||||
toggleMask() {
|
||||
this.masked = !this.masked
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
transformInput(newValue: string) {
|
||||
let i = 0
|
||||
this.value = newValue
|
||||
.split('')
|
||||
.map(x => (x === '●' ? this.value[i++] : x))
|
||||
.join('')
|
||||
this.maskedValue = this.mask.transform(this.value)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const value = this.value.trim()
|
||||
|
||||
if (!value && !this.options.nullable) return
|
||||
|
||||
try {
|
||||
await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenericInputOptions {
|
||||
// required
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
submitFn: (value: string) => Promise<any>
|
||||
// optional
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
nullable?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MarketplaceSettingsPage } from './marketplace-settings.page'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { StoreIconComponentModule } from 'src/app/components/store-icon/store-icon.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
StoreIconComponentModule,
|
||||
],
|
||||
declarations: [MarketplaceSettingsPage],
|
||||
})
|
||||
export class MarketplaceSettingsPageModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Change Registry</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group *ngIf="stores$ | async as stores">
|
||||
<ion-item-divider>Default Registries</ion-item-divider>
|
||||
<ion-item
|
||||
*ngFor="let s of stores.standard"
|
||||
detail="false"
|
||||
[button]="!s.selected"
|
||||
(click)="s.selected ? '' : presentAction(s)"
|
||||
>
|
||||
<ion-avatar slot="start">
|
||||
<store-icon [url]="s.url"></store-icon>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ s.name }}</h2>
|
||||
<p>{{ s.url }}</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="s.selected"
|
||||
slot="end"
|
||||
size="large"
|
||||
name="checkmark"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Custom Registries</ion-item-divider>
|
||||
<ion-item button detail="false" (click)="presentModalAdd()">
|
||||
<ion-icon slot="start" name="add" color="dark"></ion-icon>
|
||||
<ion-label>
|
||||
<ion-text color="dark">
|
||||
<b>Add custom registry</b>
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item
|
||||
*ngFor="let a of stores.alt"
|
||||
detail="false"
|
||||
[button]="!a.selected"
|
||||
(click)="a.selected ? '' : presentAction(a, true)"
|
||||
>
|
||||
<store-icon slot="start" [url]="a.url" size="36px"></store-icon>
|
||||
<ion-label>
|
||||
<h2>{{ a.name }}</h2>
|
||||
<p>{{ a.url }}</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="a.selected"
|
||||
slot="end"
|
||||
size="large"
|
||||
name="checkmark"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { ActionSheetButton } from '@ionic/core'
|
||||
import { ErrorToastService, sameUrl, toUrl } from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { combineLatest, firstValueFrom } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-settings',
|
||||
templateUrl: 'marketplace-settings.page.html',
|
||||
styleUrls: ['marketplace-settings.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceSettingsPage {
|
||||
stores$ = combineLatest([
|
||||
this.marketplaceService.getKnownHosts$(),
|
||||
this.marketplaceService.getSelectedHost$(),
|
||||
]).pipe(
|
||||
map(([stores, selected]) => {
|
||||
const toSlice = stores.map(s => ({
|
||||
...s,
|
||||
selected: sameUrl(s.url, selected.url),
|
||||
}))
|
||||
// 0 and 1 are prod and community
|
||||
const standard = toSlice.slice(0, 2)
|
||||
// 2 and beyond are alts
|
||||
const alt = toSlice.slice(2)
|
||||
|
||||
return { standard, alt }
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) {}
|
||||
|
||||
async dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async presentModalAdd() {
|
||||
const { name, spec } = getMarketplaceValueSpec()
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: name,
|
||||
spec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save for Later',
|
||||
handler: (value: { url: string }) => {
|
||||
this.saveOnly(value.url)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Save and Connect',
|
||||
handler: (value: { url: string }) => {
|
||||
this.saveAndConnect(value.url)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAction(
|
||||
{ url, name }: { url: string; name?: string },
|
||||
canDelete = false,
|
||||
) {
|
||||
const buttons: ActionSheetButton[] = [
|
||||
{
|
||||
text: 'Connect',
|
||||
handler: () => {
|
||||
this.connect(url)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (canDelete) {
|
||||
buttons.unshift({
|
||||
text: 'Delete',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.presentAlertDelete(url, name!)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const action = await this.actionCtrl.create({
|
||||
header: name,
|
||||
mode: 'ios',
|
||||
buttons,
|
||||
})
|
||||
|
||||
await action.present()
|
||||
}
|
||||
|
||||
private async presentAlertDelete(url: string, name: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: `Are you sure you want to delete ${name}?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => this.delete(url),
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async connect(
|
||||
url: string,
|
||||
loader?: HTMLIonLoadingElement,
|
||||
): Promise<void> {
|
||||
const message = 'Changing Registry...'
|
||||
if (!loader) {
|
||||
loader = await this.loadingCtrl.create({ message })
|
||||
await loader.present()
|
||||
} else {
|
||||
loader.message = message
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<string>(['marketplace', 'selected-url'], url)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
this.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveOnly(rawUrl: string): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create()
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl).toString()
|
||||
await this.validateAndSave(url, loader)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAndConnect(rawUrl: string): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create()
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl).toString()
|
||||
await this.validateAndSave(url, loader)
|
||||
await this.connect(url, loader)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
this.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAndSave(
|
||||
url: string,
|
||||
loader: HTMLIonLoadingElement,
|
||||
): Promise<void> {
|
||||
// Error on duplicates
|
||||
const hosts = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
|
||||
)
|
||||
const currentUrls = Object.keys(hosts).map(toUrl)
|
||||
if (currentUrls.includes(url)) throw new Error('marketplace already added')
|
||||
|
||||
// Validate
|
||||
loader.message = 'Validating marketplace...'
|
||||
await loader.present()
|
||||
|
||||
const { name } = await firstValueFrom(
|
||||
this.marketplaceService.fetchInfo$(url),
|
||||
)
|
||||
|
||||
// Save
|
||||
loader.message = 'Saving...'
|
||||
|
||||
await this.api.setDbValue<{ name: string }>(
|
||||
['marketplace', 'known-hosts', url],
|
||||
{ name },
|
||||
)
|
||||
}
|
||||
|
||||
private async delete(url: string): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
const hosts = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
|
||||
)
|
||||
|
||||
const filtered: { [url: string]: UIStore } = Object.keys(hosts)
|
||||
.filter(key => !sameUrl(key, url))
|
||||
.reduce((prev, curr) => {
|
||||
const name = hosts[curr]
|
||||
return {
|
||||
...prev,
|
||||
[curr]: name,
|
||||
}
|
||||
}, {})
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<{ [url: string]: UIStore }>(
|
||||
['marketplace', 'known-hosts'],
|
||||
filtered,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketplaceValueSpec(): ValueSpecObject {
|
||||
return {
|
||||
type: 'object',
|
||||
name: 'Add Custom Registry',
|
||||
spec: {
|
||||
url: {
|
||||
type: 'string',
|
||||
name: 'URL',
|
||||
description: 'A fully-qualified URL of the custom registry',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
pattern: `https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-\.]+[a-zA-Z0-9]\.[^\s]{2,}`,
|
||||
'pattern-description': 'Must be a valid URL',
|
||||
placeholder: 'e.g. https://example.org',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
43
web/projects/ui/src/app/modals/os-update/os-update.page.html
Normal file
43
web/projects/ui/src/app/modals/os-update/os-update.page.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<div style="padding: 10px 0">
|
||||
<ion-title style="font-size: 32px">
|
||||
StartOS {{ versions[0].version }}
|
||||
</ion-title>
|
||||
<div class="underline"></div>
|
||||
<ion-title>
|
||||
<i>Release Notes</i>
|
||||
</ion-title>
|
||||
</div>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<div style="padding: 36px" class="ion-text-left">
|
||||
<ng-container *ngFor="let v of versions">
|
||||
<h4><b>{{ v.version }}</b></h4>
|
||||
<div class="underline" style="margin: unset"></div>
|
||||
<div [innerHTML]="v.notes | markdown"></div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="updateEOS()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Begin Update
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { OSUpdatePage } from './os-update.page'
|
||||
import { MarkdownPipeModule } from '@start9labs/shared'
|
||||
|
||||
@NgModule({
|
||||
declarations: [OSUpdatePage],
|
||||
imports: [CommonModule, IonicModule, MarkdownPipeModule],
|
||||
exports: [OSUpdatePage],
|
||||
})
|
||||
export class OSUpdatePageModule {}
|
||||
@@ -0,0 +1,6 @@
|
||||
.underline {
|
||||
margin: 6px 0 8px 16px;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-color: #404040;
|
||||
}
|
||||
61
web/projects/ui/src/app/modals/os-update/os-update.page.ts
Normal file
61
web/projects/ui/src/app/modals/os-update/os-update.page.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from '../../services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
|
||||
@Component({
|
||||
selector: 'os-update',
|
||||
templateUrl: './os-update.page.html',
|
||||
styleUrls: ['./os-update.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OSUpdatePage {
|
||||
versions: { version: string; notes: string }[] = []
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly eosService: EOSService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const releaseNotes = this.eosService.eos?.['release-notes']!
|
||||
|
||||
this.versions = Object.keys(releaseNotes)
|
||||
.sort()
|
||||
.reverse()
|
||||
.map(version => {
|
||||
return {
|
||||
version,
|
||||
notes: releaseNotes[version],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async updateEOS() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Beginning update...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.updateServer()
|
||||
this.dismiss()
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { OSWelcomePage } from './os-welcome.page'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [OSWelcomePage],
|
||||
imports: [CommonModule, IonicModule, FormsModule, SharedPipesModule],
|
||||
exports: [OSWelcomePage],
|
||||
})
|
||||
export class OSWelcomePageModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Release Notes</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<h2>This Release</h2>
|
||||
|
||||
<h4>0.3.5</h4>
|
||||
<p class="note-padding">
|
||||
View the complete
|
||||
<a
|
||||
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.5"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
>
|
||||
release notes
|
||||
</a>
|
||||
for more details.
|
||||
</p>
|
||||
<h6>Highlights</h6>
|
||||
<ul class="spaced-list">
|
||||
<li>
|
||||
This release contains significant under-the-hood improvements to
|
||||
performance and reliability
|
||||
</li>
|
||||
<li>Ditch Docker, replace with Podman</li>
|
||||
<li>Remove locking behavior from PatchDB and optimize</li>
|
||||
<li>Boost efficiency of service manager</li>
|
||||
<li>Require HTTPS on LAN, and improve setup flow for trusting Root CA</li>
|
||||
<li>Better default privacy settings for Firefox kiosk mode</li>
|
||||
<li>Eliminate memory leak from Javascript runtime</li>
|
||||
<li>Other small bug fixes</li>
|
||||
<li>Update license to MIT</li>
|
||||
</ul>
|
||||
|
||||
<div class="ion-text-center ion-padding">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Begin
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,29 @@
|
||||
.close-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
.spaced-list {
|
||||
li {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.note-padding {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-style: italic;
|
||||
}
|
||||
15
web/projects/ui/src/app/modals/os-welcome/os-welcome.page.ts
Normal file
15
web/projects/ui/src/app/modals/os-welcome/os-welcome.page.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'os-welcome',
|
||||
templateUrl: './os-welcome.page.html',
|
||||
styleUrls: ['./os-welcome.page.scss'],
|
||||
})
|
||||
export class OSWelcomePage {
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/modals/snake/snake.module.ts
Normal file
12
web/projects/ui/src/app/modals/snake/snake.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [SnakePage],
|
||||
exports: [SnakePage],
|
||||
})
|
||||
export class SnakePageModule {}
|
||||
28
web/projects/ui/src/app/modals/snake/snake.page.html
Normal file
28
web/projects/ui/src/app/modals/snake/snake.page.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Play Snek!</ion-title>
|
||||
<ion-title slot="end">Score: {{ score }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="canvas-center" style="width: 100%; height: 100%">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title slot="start">High Score: {{ highScore }}</ion-title>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Save and Quit
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
6
web/projects/ui/src/app/modals/snake/snake.page.scss
Normal file
6
web/projects/ui/src/app/modals/snake/snake.page.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.canvas-center {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
255
web/projects/ui/src/app/modals/snake/snake.page.ts
Normal file
255
web/projects/ui/src/app/modals/snake/snake.page.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Component, HostListener, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'snake',
|
||||
templateUrl: './snake.page.html',
|
||||
styleUrls: ['./snake.page.scss'],
|
||||
})
|
||||
export class SnakePage {
|
||||
@Input()
|
||||
highScore = 0
|
||||
|
||||
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[] = []
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss({ highScore: 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()
|
||||
}
|
||||
|
||||
ionViewDidEnter() {
|
||||
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 = document.querySelector('canvas#game')!
|
||||
this.canvas.style.border = '1px solid #e0e0e0'
|
||||
this.context = this.canvas.getContext('2d')!
|
||||
const container = document.getElementsByClassName('canvas-center')[0]
|
||||
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() {
|
||||
await pauseFor(this.speed)
|
||||
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// move snake by it's 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user