feat: move all frontend projects under the same Angular workspace (#1141)

* feat: move all frontend projects under the same Angular workspace

* Refactor/angular workspace (#1154)

* update frontend build steps

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

View File

@@ -0,0 +1,16 @@
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 { }

View File

@@ -0,0 +1,30 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Execution Complete</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item>
<ion-label>
<h2>{{ actionRes.message }}</h2>
</ion-label>
</ion-item>
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 64px 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>

View File

@@ -0,0 +1,35 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { ActionResponse } from 'src/app/services/api/api.types'
import { copyToClipboard } from 'src/app/util/web.util'
@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'})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async dismiss () {
return this.modalCtrl.dismiss()
}
}

View File

@@ -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 { SharingModule } from 'src/app/modules/sharing.module'
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
@NgModule({
declarations: [AppConfigPage],
imports: [
CommonModule,
FormsModule,
IonicModule,
SharingModule,
FormObjectComponentModule,
ReactiveFormsModule,
],
exports: [AppConfigPage],
})
export class AppConfigPageModule { }

View File

@@ -0,0 +1,91 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Config</ion-title>
<ion-buttons *ngIf="!loadingText && !loadingError && hasConfig" slot="end" class="ion-padding-end">
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner *ngIf="loadingText; else loaded" [text]="loadingText"></text-spinner>
<!-- not loading -->
<ng-template #loaded>
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ion-item *ngIf="hasConfig && !pkg.installed.status.configured && !configForm.dirty">
<ion-label>
<ion-text color="success">To use the default config for {{ pkg.manifest.title }}, click "Save" below.</ion-text>
</ion-label>
</ion-item>
<!-- 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 config -->
<ion-item *ngIf="!hasConfig">
<ion-label>
<p>No config options for {{ pkg.manifest.title }} {{ pkg.manifest.version }}.</p>
</ion-label>
</ion-item>
<!-- has config -->
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
[current]="configForm.value"
[showEdited]="true"
></form-object>
</form>
</ng-template>
</ng-template>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons *ngIf="!loadingText && !loadingError" slot="end" class="ion-padding-end">
<ion-button *ngIf="hasConfig" fill="outline" [disabled]="saving" (click)="save()" class="enter-click" [class.no-click]="saving">
Save
</ion-button>
<ion-button *ngIf="!hasConfig" fill="outline" (click)="dismiss()" class="enter-click">
Close
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,8 @@
.notifier-item {
margin: 12px;
margin-top: 0px;
border-radius: 12px;
// kills the lines
--border-width: 0;
--inner-border-width: 0;
}

View File

@@ -0,0 +1,249 @@
import { Component, Input, ViewChild } from '@angular/core'
import { AlertController, ModalController, IonContent, LoadingController, IonicSafeString } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DependentInfo, isEmptyObject, isObject } from 'src/app/util/misc.util'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ErrorToastService, getErrorMessage } from 'src/app/services/error-toast.service'
import { FormGroup } from '@angular/forms'
import { convertValuesRecursive, FormService } from 'src/app/services/form.service'
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
})
export class AppConfigPage {
@ViewChild(IonContent) content: IonContent
@Input() pkgId: string
@Input() dependentInfo?: DependentInfo
diff: string[] // only if dependent info
pkg: PackageDataEntry
loadingText: string | undefined
configSpec: ConfigSpec
configForm: FormGroup
original: object
hasConfig = false
saving = false
loadingError: string | IonicSafeString
constructor (
private readonly wizardBaker: WizardBaker,
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: PatchDbService,
) { }
async ngOnInit () {
this.pkg = this.patch.getData()['package-data'][this.pkgId]
this.hasConfig = !!this.pkg.manifest.config
if (!this.hasConfig) return
try {
let oldConfig: object
let newConfig: object
let spec: ConfigSpec
let patch: Operation[]
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 })
oldConfig = oc
newConfig = nc
spec = s
patch = compare(oldConfig, newConfig)
} else {
this.loadingText = 'Loading Config'
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({ id: this.pkgId })
oldConfig = c
spec = s
}
this.original = oldConfig
this.configSpec = spec
this.configForm = this.formService.createForm(spec, newConfig || oldConfig)
this.configForm.markAllAsTouched()
if (patch) {
this.diff = this.getDiff(patch)
this.markDirty(patch)
}
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loadingText = undefined
}
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
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) {
await this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
}
}
async save () {
convertValuesRecursive(this.configSpec, this.configForm)
if (this.configForm.invalid) {
document.getElementsByClassName('validation-error')[0].parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
return
}
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: `Saving config...`,
cssClass: 'loader',
})
await loader.present()
this.saving = true
try {
const config = this.configForm.value
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
if (!isEmptyObject(breakages['length'])) {
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.configure({
pkg: this.pkg,
breakages,
}),
)
if (cancelled) return
}
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config,
})
this.modalCtrl.dismiss()
} catch (e) {
this.errToast.present(e)
} finally {
this.saving = false
loader.dismiss()
}
}
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(' &rarr; ')
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()
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppRecoverSelectPage } from './app-recover-select.page'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [AppRecoverSelectPage],
imports: [
CommonModule,
IonicModule,
FormsModule,
],
exports: [AppRecoverSelectPage],
})
export class AppRecoverSelectPageModule { }

View File

@@ -0,0 +1,42 @@
<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 : 'short' }}</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 EmbassyOS.</ion-text>
</p>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="option.checked" [disabled]="option.installed || option['newer-eos']" (ionChange)="handleChange()"></ion-checkbox>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button [disabled]="!hasSelection" fill="outline" (click)="restore()" class="enter-click">
Restore Selected
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,84 @@
import { Component, Input } from '@angular/core'
import { LoadingController, ModalController, IonicSafeString } from '@ionic/angular'
import { BackupInfo, PackageBackupInfo } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { Emver } from 'src/app/services/emver.service'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@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
options: (PackageBackupInfo & {
id: string
checked: boolean
installed: boolean
'newer-eos': boolean
})[]
hasSelection = false
error: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly embassyApi: ApiService,
private readonly config: ConfigService,
private readonly emver: Emver,
private readonly patch: PatchDbService,
) { }
ngOnInit () {
this.options = Object.keys(this.backupInfo['package-backups']).map(id => {
return {
...this.backupInfo['package-backups'][id],
id,
checked: false,
installed: !!this.patch.getData()['package-data'][id],
'newer-eos': this.emver.compare(this.backupInfo['package-backups'][id]['os-version'], this.config.version) === 1,
}
})
}
dismiss () {
this.modalCtrl.dismiss()
}
handleChange () {
this.hasSelection = this.options.some(o => o.checked)
}
async restore (): Promise<void> {
const ids = this.options
.filter(option => !!option.checked)
.map(option => option.id)
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Initializing...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.restorePackages({
ids,
'target-id': this.id,
'old-password': this.oldPassword,
password: this.password,
})
this.modalCtrl.dismiss(undefined, 'success')
} catch (e) {
this.error = getErrorMessage(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,14 @@
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 { }

View File

@@ -0,0 +1,30 @@
<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 : 'short' }}</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>

View File

@@ -0,0 +1,48 @@
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)
}
}

View File

@@ -0,0 +1,16 @@
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 { }

View File

@@ -0,0 +1,36 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>
{{ spec.name }}
</ion-title>
<ion-buttons slot="end">
<ion-button slot="end" fill="clear" (click)="toggleSelectAll()">
{{ selectAll ? 'All' : 'None' }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-group>
<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="outline" (click)="save()" class="enter-click">
Done
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,47 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ValueSpecListOf } from '../../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 = true
constructor (
private readonly modalCtrl: ModalController,
) { }
ngOnInit () {
for (let val of this.spec.spec.values) {
this.options[val] = this.current.includes(val)
}
}
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
}
}

View File

@@ -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 { }

View File

@@ -0,0 +1,30 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
</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>

View File

@@ -0,0 +1,9 @@
button:disabled,
button[disabled]{
border: 1px solid #999999;
background-color: #cccccc;
color: #666666;
}
button {
color: var(--ion-color-primary);
}

View File

@@ -0,0 +1,56 @@
import { Component, Input } from '@angular/core'
import { FormGroup } 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: FormGroup
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) {
this.formGroup.markAllAsTouched()
document.getElementsByClassName('validation-error')[0].parentElement.parentElement.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()
}
}

View File

@@ -0,0 +1,41 @@
<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="dark">
<ion-input #mainInput [type]="options.useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [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]="unmasked ? '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>

View File

@@ -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 { SharingModule } from 'src/app/modules/sharing.module'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [GenericInputComponent],
imports: [
CommonModule,
IonicModule,
FormsModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [GenericInputComponent],
})
export class GenericInputComponentModule { }

View File

@@ -0,0 +1,76 @@
import { Component, Input, ViewChild } from '@angular/core'
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
import { getErrorMessage } from 'src/app/services/error-toast.service'
@Component({
selector: 'generic-input',
templateUrl: './generic-input.component.html',
styleUrls: ['./generic-input.component.scss'],
})
export class GenericInputComponent {
@ViewChild('mainInput') elem: IonInput
@Input() options: GenericInputOptions
value: string
unmasked = false
error: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
) { }
ngOnInit () {
const defaultOptions: Partial<GenericInputOptions> = {
buttonText: 'Submit',
placeholder: 'Enter value',
nullable: false,
useMask: false,
initialValue: '',
}
this.options = {
...defaultOptions,
...this.options,
}
this.value = this.options.initialValue
}
ngAfterViewInit () {
setTimeout(() => this.elem.setFocus(), 400)
}
toggleMask () {
this.unmasked = !this.unmasked
}
cancel () {
this.modalCtrl.dismiss()
}
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) {
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
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { MarkdownPage } from './markdown.page'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [MarkdownPage],
imports: [
CommonModule,
IonicModule,
SharingModule,
],
exports: [MarkdownPage],
})
export class MarkdownPageModule { }

View File

@@ -0,0 +1,29 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ title | titlecase }}</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>
<text-spinner *ngIf="loading; else loaded" [text]="'Loading ' + title | titlecase"></text-spinner>
<ng-template #loaded>
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<div class="content-padding" [innerHTML]="content | markdown"></div>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,3 @@
.content-padding {
padding: 0 16px 16px 16px
}

View File

@@ -0,0 +1,44 @@
import { Component, Input } from '@angular/core'
import { ModalController, IonicSafeString } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getErrorMessage } from 'src/app/services/error-toast.service'
@Component({
selector: 'markdown',
templateUrl: './markdown.page.html',
styleUrls: ['./markdown.page.scss'],
})
export class MarkdownPage {
@Input() contentUrl: string
@Input() title: string
content: string
loading = true
loadingError: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
) { }
async ngOnInit () {
try {
this.content = await this.embassyApi.getStatic(this.contentUrl)
const links = document.links
for (let i = 0, linksLength = links.length; i < linksLength; i++) {
if (links[i].hostname != window.location.hostname) {
links[i].target = '_blank'
links[i].setAttribute('rel', 'noreferrer')
links[i].className += ' externalLink'
}
}
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
async dismiss () {
return this.modalCtrl.dismiss(true)
}
}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { OSWelcomePage } from './os-welcome.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [OSWelcomePage],
imports: [
CommonModule,
IonicModule,
FormsModule,
SharingModule,
],
exports: [OSWelcomePage],
})
export class OSWelcomePageModule { }

View File

@@ -0,0 +1,20 @@
<ion-header>
<ion-toolbar>
<ion-title>Welcome to {{ version }}!</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div style="display: flex; flex-direction: column; justify-content: space-between; height: 100%">
<h2>A Whole New Embassy</h2>
<div class="main-content">
<p>New features and more new features!</p>
</div>
<div class="close-button">
<ion-button fill="outline" color="dark" (click)="dismiss()">
Begin
</ion-button>
</div>
</div>
</ion-content>

View File

@@ -0,0 +1,12 @@
.close-button {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
}
.main-content {
height: 100%;
color: var(--ion-color-dark);
}

View File

@@ -0,0 +1,19 @@
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 {
@Input() version: string
constructor (
private readonly modalCtrl: ModalController,
) { }
async dismiss () {
return this.modalCtrl.dismiss()
}
}