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:
Matt Hill
2023-11-13 14:22:23 -07:00
committed by GitHub
parent 871f78b570
commit 86567e7fa5
968 changed files with 812 additions and 6672 deletions

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadChildren: () =>
import('./pages/logs/logs.module').then(m => m.LogsPageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,5 @@
<tui-root>
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
</tui-root>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor() {}
}

View File

@@ -0,0 +1,43 @@
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import { TuiRootModule } from '@taiga-ui/core'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
imports: [
HttpClientModule,
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
}),
AppRoutingModule,
TuiRootModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { HomePage } from './home.page'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomePageRoutingModule {}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { HomePageRoutingModule } from './home-routing.module'
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -0,0 +1,81 @@
<ion-content>
<div style="padding: 48px">
<ng-container *ngIf="!restarted; else refresh">
<h1
class="ion-text-center"
style="padding-bottom: 36px; font-size: calc(2vw + 14px)"
>
StartOS - Diagnostic Mode
</h1>
<ng-container *ngIf="error">
<h2
style="
padding-bottom: 16px;
font-size: calc(1vw + 14px);
font-weight: bold;
"
>
StartOS launch error:
</h2>
<div class="code-block">
<code>
<ion-text color="warning">{{ error.problem }}</ion-text>
<span *ngIf="error.details">
<br />
<br />
<ion-text color="warning">{{ error.details }}</ion-text>
</span>
</code>
</div>
<ion-button routerLink="logs">View Logs</ion-button>
<h2
style="
padding: 32px 0 16px 0;
font-size: calc(1vw + 12px);
font-weight: bold;
"
>
Possible solutions:
</h2>
<div class="code-block">
<code><ion-text color="success">{{ error.solution }}</ion-text></code>
</div>
<ion-button (click)="restart()">Restart Server</ion-button>
<ion-button
class="ion-padding-start"
*ngIf="error.code === 15 || error.code === 25"
(click)="forgetDrive()"
>
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'
}}
</ion-button>
<div class="ion-padding-top">
<ion-button (click)="presentAlertSystemRebuild()" color="warning">
System Rebuild
</ion-button>
</div>
<div class="ion-padding-top">
<ion-button (click)="presentAlertRepairDisk()" color="danger">
Repair Drive
</ion-button>
</div>
</ng-container>
</ng-container>
<ng-template #refresh>
<h1
class="ion-text-center"
style="padding-bottom: 36px; font-size: calc(2vw + 12px)"
>
Server is restarting
</h1>
<h2 style="padding-bottom: 16px; font-size: calc(1vw + 12px)">
Wait for the server to restart, then refresh this page.
</h2>
<ion-button (click)="refreshPage()">Refresh</ion-button>
</ng-template>
</div>
</ion-content>

View File

@@ -0,0 +1,5 @@
.code-block {
background-color: rgb(69, 69, 69);
padding: 12px;
margin-bottom: 32px;
}

View File

@@ -0,0 +1,210 @@
import { Component } from '@angular/core'
import { AlertController, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
error?: {
code: number
problem: string
solution: string
details?: string
}
solutions: string[] = []
restarted = false
constructor(
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
) {}
async ngOnInit() {
try {
const error = await this.api.getError()
// incorrect drive
if (error.code === 15) {
this.error = {
code: 15,
problem: 'Unknown storage drive detected',
solution:
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
details: error.data?.details,
}
// no drive
} else if (error.code === 20) {
this.error = {
code: 20,
problem: 'Storage drive not found',
solution:
'Insert your StartOS storage drive and click RESTART SERVER below.',
details: error.data?.details,
}
// drive corrupted
} else if (error.code === 25) {
this.error = {
code: 25,
problem:
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
solution:
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
details: error.data?.details,
}
// filesystem I/O error - disk needs repair
} else if (error.code === 2) {
this.error = {
code: 2,
problem: 'Filesystem I/O error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
// disk management error - disk needs repair
} else if (error.code === 48) {
this.error = {
code: 48,
problem: 'Disk management error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
} else {
this.error = {
code: error.code,
problem: error.message,
solution: 'Please contact support.',
details: error.data?.details,
}
}
} catch (e) {
console.error(e)
}
}
async restart(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
})
await loader.present()
try {
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.dismiss()
}
}
async forgetDrive(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
})
await loader.present()
try {
await this.api.forgetDrive()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.dismiss()
}
}
async presentAlertSystemRebuild() {
const alert = await this.alertCtrl.create({
header: 'Warning',
message:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Rebuild',
handler: () => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
},
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
}
async presentAlertRepairDisk() {
const alert = await this.alertCtrl.create({
header: 'Warning',
message:
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Repair',
handler: () => {
try {
this.repairDisk()
} catch (e) {
console.error(e)
}
},
},
],
cssClass: 'alert-error-message',
})
await alert.present()
}
refreshPage(): void {
window.location.reload()
}
private async systemRebuild(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
})
await loader.present()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.dismiss()
}
}
private async repairDisk(): Promise<void> {
const loader = await this.loadingCtrl.create({
cssClass: 'loader',
})
await loader.present()
try {
await this.api.repairDisk()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { LogsPage } from './logs.page'
const routes: Routes = [
{
path: '',
component: LogsPage,
},
]
@NgModule({
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
declarations: [LogsPage],
})
export class LogsPageModule {}

View File

@@ -0,0 +1,57 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/"></ion-back-button>
</ion-buttons>
<ion-title>Logs</ion-title>
</ion-toolbar>
</ion-header>
<ion-content
[scrollEvents]="true"
(ionScrollEnd)="scrollEnd()"
class="ion-padding"
>
<ion-infinite-scroll
id="scroller"
*ngIf="!loading && needInfinite"
position="top"
threshold="0"
(ionInfinite)="doInfinite($event)"
>
<ion-infinite-scroll-content
loadingSpinner="lines"
></ion-infinite-scroll-content>
</ion-infinite-scroll>
<div id="container">
<div id="template" style="white-space: pre-line"></div>
</div>
<div id="bottom-div"></div>
<div
[ngStyle]="{
'position': 'fixed',
'bottom': '50px',
'right': isOnBottom ? '-52px' : '30px',
'border-radius': '100%',
'transition': 'right 0.25s ease-out'
}"
>
<ion-button
style="
width: 50px;
height: 50px;
--padding-start: 0px;
--padding-end: 0px;
--border-radius: 100%;
"
color="dark"
(click)="scrollToBottom()"
strong
>
<ion-icon name="chevron-down"></ion-icon>
</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,95 @@
import { Component, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { ErrorToastService, toLocalIsoString } from '@start9labs/shared'
var Convert = require('ansi-to-html')
var convert = new Convert({
bg: 'transparent',
})
@Component({
selector: 'logs',
templateUrl: './logs.page.html',
styleUrls: ['./logs.page.scss'],
})
export class LogsPage {
@ViewChild(IonContent) private content?: IonContent
loading = true
needInfinite = true
startCursor?: string
limit = 200
isOnBottom = true
constructor(
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
async ngOnInit() {
await this.getLogs()
this.loading = false
}
scrollEnd() {
const bottomDiv = document.getElementById('bottom-div')
this.isOnBottom =
!!bottomDiv &&
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(500)
}
async doInfinite(e: any): Promise<void> {
await this.getLogs()
e.target.complete()
}
private async getLogs() {
try {
const { 'start-cursor': startCursor, entries } = await this.api.getLogs({
cursor: this.startCursor,
before: !!this.startCursor,
limit: this.limit,
})
if (!entries.length) return
this.startCursor = startCursor
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode(true)
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = entries
.map(
entry =>
`<b>${toLocalIsoString(
new Date(entry.timestamp),
)}</b> ${convert.toHtml(entry.message)}`,
)
.join('\n')
const beforeContainerHeight = container?.scrollHeight || 0
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// scroll down
setTimeout(() => {
this.content?.scrollToPoint(
0,
afterContainerHeight - beforeContainerHeight,
)
}, 50)
if (entries.length < this.limit) {
this.needInfinite = false
}
} catch (e: any) {
this.errToast.present(e)
}
}
}

View File

@@ -0,0 +1,16 @@
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
export abstract class ApiService {
abstract getError(): Promise<GetErrorRes>
abstract restart(): Promise<void>
abstract forgetDrive(): Promise<void>
abstract repairDisk(): Promise<void>
abstract systemRebuild(): Promise<void>
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
}
export interface GetErrorRes {
code: number
message: string
data: { details: string }
}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@angular/core'
import {
HttpService,
isRpcError,
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
@Injectable()
export class LiveApiService implements ApiService {
constructor(private readonly http: HttpService) {}
async getError(): Promise<GetErrorRes> {
return this.rpcRequest<GetErrorRes>({
method: 'diagnostic.error',
params: {},
})
}
async restart(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.restart',
params: {},
})
}
async forgetDrive(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.forget',
params: {},
})
}
async repairDisk(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.disk.repair',
params: {},
})
}
async systemRebuild(): Promise<void> {
return this.rpcRequest<void>({
method: 'diagnostic.rebuild',
params: {},
})
}
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
return this.rpcRequest<LogsRes>({
method: 'diagnostic.logs',
params,
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
throw new RpcError(rpcRes.error)
}
return rpcRes.result
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { ApiService, GetErrorRes } from './api.service'
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
@Injectable()
export class MockApiService implements ApiService {
async getError(): Promise<GetErrorRes> {
await pauseFor(1000)
return {
code: 15,
message: 'Unknown server',
data: { details: 'Some details about the error here' },
}
}
async restart(): Promise<void> {
await pauseFor(1000)
}
async forgetDrive(): Promise<void> {
await pauseFor(1000)
}
async repairDisk(): Promise<void> {
await pauseFor(1000)
}
async systemRebuild(): Promise<void> {
await pauseFor(1000)
}
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
await pauseFor(1000)
let entries: Log[]
if (Math.random() < 0.2) {
entries = packageLogs
} else {
const arrLength = params.limit
? Math.ceil(params.limit / packageLogs.length)
: 10
entries = new Array(arrLength)
.fill(packageLogs)
.reduce((acc, val) => acc.concat(val), [])
}
return {
entries,
'start-cursor': 'startCursor',
'end-cursor': 'endCursor',
}
}
}
const packageLogs = [
{
timestamp: '2019-12-26T14:20:30.872Z',
message: '****** START *****',
},
{
timestamp: '2019-12-26T14:21:30.872Z',
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
},
{
timestamp: '2019-12-26T14:22:30.872Z',
message: '****** FINISH *****',
},
]

View File

@@ -0,0 +1,3 @@
export const environment = {
production: true,
}

View File

@@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
}
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartOS Diagnostic UI</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="icon" type="image/png" href="assets/icon/favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'
import { environment } from './environments/environment'
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err))

View File

@@ -0,0 +1,64 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
import './zone-flags'
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -0,0 +1,41 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Montserrat';
--ion-color-primary: #0075e1;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152,154,162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0,0,0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34,36,40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255,255,255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
--ion-item-background: #2b2b2b;
--ion-toolbar-background: #2b2b2b;
--ion-card-background: #2b2b2b;
--ion-background-color: #282828;
--ion-background-color-rgb: 30,30,30;
--ion-text-color: var(--ion-color-dark);
--ion-text-color-rgb: var(--ion-color-dark-rgb);
}
.loader {
--spinner-color: var(--ion-color-warning) !important;
z-index: 40000 !important;
}

View File

@@ -0,0 +1,6 @@
/**
* Prevents Angular change detection from
* running with certain Web Component callbacks
*/
// eslint-disable-next-line no-underscore-dangle
(window as any).__Zone_disable_customElements = true

View File

@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,5 @@
<tui-root>
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
</tui-root>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor() {}
}

View File

@@ -0,0 +1,43 @@
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import { TuiRootModule } from '@taiga-ui/core'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
imports: [
HttpClientModule,
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
}),
AppRoutingModule,
TuiRootModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { SwiperModule } from 'swiper/angular'
import {
UnitConversionPipesModule,
GuidPipePipesModule,
} from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
SwiperModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
declarations: [HomePage],
})
export class HomePageModule {}

View File

@@ -0,0 +1,107 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding: 64px 0 32px 0">
<img src="assets/img/icon.png" style="max-width: 100px" />
</div>
<ion-card color="dark">
<ion-card-header>
<ion-button
*ngIf="swiper?.activeIndex === 1"
class="back-button"
fill="clear"
color="light"
(click)="previous()"
>
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>
<ion-card-title>
{{ !swiper || swiper.activeIndex === 0 ? 'Select Disk' : 'Install
Type' }}
</ion-card-title>
<ion-card-subtitle *ngIf="error">
<ion-text color="danger">{{ error }}</ion-text>
</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin-bottom">
<swiper (swiper)="setSwiperInstance($event)">
<!-- SLIDE 1 -->
<ng-template swiperSlide>
<ion-item
*ngFor="let disk of disks"
button
(click)="next(disk)"
>
<ion-icon
slot="start"
name="save-outline"
size="large"
color="dark"
></ion-icon>
<ion-label class="ion-text-wrap">
<h1>
{{ disk.vendor || 'Unknown Vendor' }} - {{ disk.model ||
'Unknown Model' }}
</h1>
<h2>
{{ disk.logicalname }} - {{ disk.capacity | convertBytes
}}
</h2>
</ion-label>
</ion-item>
</ng-template>
<!-- SLIDE 2 -->
<ng-template swiperSlide>
<ng-container *ngIf="selectedDisk">
<!-- re-install -->
<ion-item
*ngIf="selectedDisk | guid"
button
(click)="tryInstall(false)"
>
<ion-icon
color="dark"
slot="start"
size="large"
name="medkit-outline"
></ion-icon>
<ion-label>
<h1>
<ion-text color="success">Re-Install StartOS</ion-text>
</h1>
<h2>Will preserve existing StartOS data</h2>
</ion-label>
</ion-item>
<!-- fresh install -->
<ion-item button lines="none" (click)="tryInstall(true)">
<ion-icon
color="dark"
slot="start"
size="large"
name="download-outline"
></ion-icon>
<ion-label>
<h1>
<ion-text
[color]="(selectedDisk | guid) ? 'danger' : 'success'"
>
{{ (selectedDisk | guid) ? 'Factory Reset' : 'Install
StartOS' }}
</ion-text>
</h1>
<h2>Will delete existing data on disk</h2>
</ion-label>
</ion-item>
</ng-container>
</ng-template>
</swiper>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,28 @@
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Benton Sans';
}
ion-content {
--background: var(--ion-color-medium);
}
ion-grid {
padding-top: 32px;
height: 100%;
max-width: 640px;
}
.back-button {
position: absolute;
left: 16px;
top: 24px;
z-index: 1000000;
}
ion-card-title {
margin: 16px 0;
font-family: 'Montserrat';
font-size: x-large;
--color: var(--ion-color-light);
}

View File

@@ -0,0 +1,141 @@
import { Component } from '@angular/core'
import { AlertController, IonicSlides, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import SwiperCore, { Swiper } from 'swiper'
import { DiskInfo } from '@start9labs/shared'
SwiperCore.use([IonicSlides])
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
swiper?: Swiper
disks: DiskInfo[] = []
selectedDisk?: DiskInfo
error = ''
constructor(
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
) {}
async ngOnInit() {
this.disks = await this.api.getDisks()
}
async ionViewDidEnter() {
if (this.swiper) {
this.swiper.allowTouchMove = false
}
}
setSwiperInstance(swiper: any) {
this.swiper = swiper
}
next(disk: DiskInfo) {
this.selectedDisk = disk
this.swiper?.slideNext(500)
}
previous() {
this.swiper?.slidePrev(500)
}
async tryInstall(overwrite: boolean) {
if (overwrite) {
return this.presentAlertDanger()
}
this.install(false)
}
private async install(overwrite: boolean) {
const loader = await this.loadingCtrl.create({
message: 'Installing StartOS...',
})
await loader.present()
try {
await this.api.install({
logicalname: this.selectedDisk!.logicalname,
overwrite,
})
this.presentAlertReboot()
} catch (e: any) {
this.error = e.message
} finally {
loader.dismiss()
}
}
private async presentAlertDanger() {
const { vendor, model } = this.selectedDisk!
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `This action will COMPLETELY erase the disk ${
vendor || 'Unknown Vendor'
} - ${model || 'Unknown Model'} and install StartOS in its place`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Continue',
handler: () => {
this.install(true)
},
},
],
cssClass: 'alert-danger-message',
})
await alert.present()
}
private async presentAlertReboot() {
const alert = await this.alertCtrl.create({
header: 'Install Success',
message:
'Remove the USB stick and reboot your device to begin using your new Start9 server',
buttons: [
{
text: 'Reboot',
handler: () => {
this.reboot()
},
},
],
cssClass: 'alert-success-message',
})
await alert.present()
}
private async reboot() {
const loader = await this.loadingCtrl.create()
await loader.present()
try {
await this.api.reboot()
this.presentAlertComplete()
} catch (e: any) {
this.error = e.message
} finally {
loader.dismiss()
}
}
private async presentAlertComplete() {
const alert = await this.alertCtrl.create({
header: 'Rebooting',
message: 'Please wait for StartOS to restart, then refresh this page',
buttons: ['OK'],
})
await alert.present()
}
}

View File

@@ -0,0 +1,14 @@
import { DiskInfo } from '@start9labs/shared'
export abstract class ApiService {
abstract getDisks(): Promise<GetDisksRes> // install.disk.list
abstract install(params: InstallReq): Promise<void> // install.execute
abstract reboot(): Promise<void> // install.reboot
}
export type GetDisksRes = DiskInfo[]
export type InstallReq = {
logicalname: string
overwrite: boolean
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core'
import {
HttpService,
isRpcError,
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { ApiService, GetDisksRes, InstallReq } from './api.service'
@Injectable()
export class LiveApiService implements ApiService {
constructor(private readonly http: HttpService) {}
async getDisks(): Promise<GetDisksRes> {
return this.rpcRequest({
method: 'install.disk.list',
params: {},
})
}
async install(params: InstallReq): Promise<void> {
return this.rpcRequest<void>({
method: 'install.execute',
params,
})
}
async reboot(): Promise<void> {
return this.rpcRequest<void>({
method: 'install.reboot',
params: {},
})
}
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
const res = await this.http.rpcRequest<T>(opts)
const rpcRes = res.body
if (isRpcError(rpcRes)) {
throw new RpcError(rpcRes.error)
}
return rpcRes.result
}
}

View File

@@ -0,0 +1,89 @@
import { Injectable } from '@angular/core'
import { pauseFor } from '@start9labs/shared'
import { ApiService, GetDisksRes, InstallReq } from './api.service'
@Injectable()
export class MockApiService implements ApiService {
async getDisks(): Promise<GetDisksRes> {
await pauseFor(500)
return [
{
logicalname: 'abcd',
vendor: 'Samsung',
model: 'T5',
partitions: [
{
logicalname: 'pabcd',
label: null,
capacity: 73264762332,
used: null,
'embassy-os': {
version: '0.2.17',
full: true,
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': null,
},
guid: null,
},
],
capacity: 123456789123,
guid: 'uuid-uuid-uuid-uuid',
},
{
logicalname: 'dcba',
vendor: 'Crucial',
model: 'MX500',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
'embassy-os': {
version: '0.3.3',
full: true,
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': null,
},
guid: null,
},
],
capacity: 124456789123,
guid: null,
},
{
logicalname: 'wxyz',
vendor: 'SanDisk',
model: 'Specialness',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
'embassy-os': {
version: '0.3.2',
full: true,
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': null,
},
guid: 'guid-guid-guid-guid',
},
],
capacity: 123459789123,
guid: null,
},
]
}
async install(params: InstallReq): Promise<void> {
await pauseFor(1000)
}
async reboot(): Promise<void> {
await pauseFor(1000)
}
}

View File

@@ -0,0 +1,3 @@
export const environment = {
production: true,
}

View File

@@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
}
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>StartOS Install Wizard</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="icon" type="image/png" href="assets/icon/favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'
import { environment } from './environments/environment'
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic()
.bootstrapModule(AppModule)
.catch(err => console.error(err))

View File

@@ -0,0 +1,64 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
import './zone-flags'
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -0,0 +1,59 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Montserrat';
--ion-color-primary: #0075e1;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152,154,162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0,0,0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-light: #222428;
--ion-color-light-rgb: 34,36,40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255,255,255;
--ion-color-light-shade: #1e2023;
--ion-color-light-tint: #383a3e;
--ion-item-background: #2b2b2b;
--ion-toolbar-background: #2b2b2b;
--ion-card-background: #2b2b2b;
--ion-background-color: #282828;
--ion-background-color-rgb: 30,30,30;
--ion-text-color: var(--ion-color-dark);
--ion-text-color-rgb: var(--ion-color-dark-rgb);
}
.loader {
--spinner-color: var(--ion-color-warning) !important;
z-index: 40000 !important;
}
.alert-danger-message {
.alert-title {
color: var(--ion-color-danger);
}
}
.alert-success-message {
.alert-title {
color: var(--ion-color-success);
}
}
ion-alert {
.alert-button {
color: var(--ion-color-dark) !important;
}
}

View File

@@ -0,0 +1,6 @@
/**
* Prevents Angular change detection from
* running with certain Web Component callbacks
*/
// eslint-disable-next-line no-underscore-dangle
(window as any).__Zone_disable_customElements = true

View File

@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/marketplace",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@start9labs/marketplace",
"version": "0.3.11",
"peerDependencies": {
"@angular/common": ">=13.2.0",
"@angular/core": ">=13.2.0",
"@ionic/angular": ">=6.0.0",
"@start9labs/shared": ">=0.3.0",
"@taiga-ui/cdk": ">=3.0.0",
"fuse.js": "^6.4.6"
},
"dependencies": {
"tslib": "^2.3.0"
}
}

View File

@@ -0,0 +1,9 @@
<ion-button
*ngFor="let cat of categories"
fill="clear"
class="category"
[class.category_selected]="cat === category"
(click)="switchCategory(cat)"
>
{{ cat }}
</ion-button>

View File

@@ -0,0 +1,14 @@
:host {
display: block;
}
.category {
font-weight: 300;
color: var(--ion-color-dark-shade);
&_selected {
font-weight: bold;
font-size: 17px;
color: var(--color);
}
}

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
@Component({
selector: 'marketplace-categories',
templateUrl: 'categories.component.html',
styleUrls: ['categories.component.scss'],
host: {
class: 'hidden-scrollbar ion-text-center',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CategoriesComponent {
@Input()
categories: readonly string[] = []
@Input()
category = ''
@Output()
readonly categoryChange = new EventEmitter<string>()
switchCategory(category: string): void {
this.category = category
this.categoryChange.emit(category)
}
}

View File

@@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { CategoriesComponent } from './categories.component'
@NgModule({
imports: [CommonModule, IonicModule],
declarations: [CategoriesComponent],
exports: [CategoriesComponent],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,12 @@
<ion-item class="service-card" [routerLink]="['/marketplace', pkg.manifest.id]">
<ion-thumbnail slot="start">
<img alt="" [src]="pkg | mimeType | trustUrl" />
</ion-thumbnail>
<ion-label>
<h2 class="montserrat">
<strong>{{ pkg.manifest.title }}</strong>
</h2>
<h3>{{ pkg.manifest.description.short }}</h3>
<ng-content></ng-content>
</ion-label>
</ion-item>

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-item',
templateUrl: 'item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ItemComponent {
@Input()
pkg!: MarketplacePkg
}

View File

@@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharedPipesModule } from '@start9labs/shared'
import { ItemComponent } from './item.component'
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
@NgModule({
declarations: [ItemComponent],
exports: [ItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule,
SharedPipesModule,
MimeTypePipeModule,
],
})
export class ItemModule {}

View File

@@ -0,0 +1,14 @@
<ion-grid>
<ion-row>
<ion-col responsiveCol class="column" sizeSm="8" sizeLg="6">
<ion-toolbar color="transparent" class="ion-text-left">
<ion-searchbar
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
debounce="250"
[ngModel]="query"
(ngModelChange)="onModelChange($event)"
></ion-searchbar>
</ion-toolbar>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
padding-bottom: 32px;
}
.column {
margin: 0 auto;
}

View File

@@ -0,0 +1,30 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { THEME } from '@start9labs/shared'
@Component({
selector: 'marketplace-search',
templateUrl: 'search.component.html',
styleUrls: ['search.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchComponent {
@Input()
query = ''
@Output()
readonly queryChange = new EventEmitter<string>()
readonly theme$ = inject(THEME)
onModelChange(query: string) {
this.query = query
this.queryChange.emit(query)
}
}

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { ResponsiveColModule } from '@start9labs/shared'
import { SearchComponent } from './search.component'
@NgModule({
imports: [IonicModule, FormsModule, CommonModule, ResponsiveColModule],
declarations: [SearchComponent],
exports: [SearchComponent],
})
export class SearchModule {}

View File

@@ -0,0 +1,39 @@
<div class="hidden-scrollbar ion-text-center">
<ion-button *ngFor="let cat of ['', '', '', '', '', '', '']" fill="clear">
<ion-skeleton-text
animated
style="width: 80px; border-radius: 0"
></ion-skeleton-text>
</ion-button>
</div>
<div class="divider" style="margin: 24px 0"></div>
<ion-grid>
<ion-row>
<ion-col
*ngFor="let pkg of ['', '', '', '']"
responsiveCol
sizeXs="12"
sizeSm="12"
sizeMd="6"
>
<ion-item>
<ion-thumbnail slot="start">
<ion-skeleton-text
style="border-radius: 100%"
animated
></ion-skeleton-text>
</ion-thumbnail>
<ion-label>
<ion-skeleton-text
animated
style="width: 150px; height: 18px; margin-bottom: 8px"
></ion-skeleton-text>
<ion-skeleton-text animated style="width: 400px"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px"></ion-skeleton-text>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'marketplace-skeleton',
templateUrl: 'skeleton.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SkeletonComponent {}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { ResponsiveColModule } from '@start9labs/shared'
import { SkeletonComponent } from './skeleton.component'
@NgModule({
imports: [CommonModule, IonicModule, ResponsiveColModule],
declarations: [SkeletonComponent],
exports: [SkeletonComponent],
})
export class SkeletonModule {}

View File

@@ -0,0 +1,32 @@
<ion-content class="with-widgets">
<ng-container *ngIf="notes$ | async as notes; else loading">
<div *ngFor="let note of notes | keyvalue: asIsOrder">
<ion-button
expand="full"
color="light"
class="version-button"
[class.ion-activated]="isSelected(note.key)"
(click)="setSelected(note.key)"
>
<p class="version">{{ note.key | displayEmver }}</p>
</ion-button>
<ion-card
tuiElement
#element="elementRef"
class="panel"
color="light"
[id]="note.key"
[style.maxHeight.px]="getDocSize(note.key, element)"
>
<ion-text
id="release-notes"
[innerHTML]="note.value | markdown"
></ion-text>
</ion-card>
</div>
</ng-container>
<ng-template #loading>
<text-spinner text="Loading Release Notes"></text-spinner>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,23 @@
:host {
flex: 1 1 0;
}
.panel {
margin: 0;
padding: 0 24px;
transition: max-height 0.2s ease-out;
}
.active {
border: 5px solid #4d4d4d;
}
.version-button {
height: 50px;
margin: 1px;
}
.version {
position: absolute;
left: 10px;
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, ElementRef } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { AbstractMarketplaceService } from '../../services/marketplace.service'
@Component({
selector: 'release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseNotesComponent {
private readonly pkgId = getPkgId(this.route)
private selected: string | null = null
readonly notes$ = this.marketplaceService.fetchReleaseNotes$(this.pkgId)
constructor(
private readonly route: ActivatedRoute,
private readonly marketplaceService: AbstractMarketplaceService,
) {}
isSelected(key: string): boolean {
return this.selected === key
}
setSelected(selected: string) {
this.selected = this.isSelected(selected) ? null : selected
}
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
return this.isSelected(key) ? nativeElement.scrollHeight : 0
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
EmverPipesModule,
MarkdownPipeModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
import { TuiElementModule } from '@taiga-ui/cdk'
import { ReleaseNotesComponent } from './release-notes.component'
@NgModule({
imports: [
CommonModule,
IonicModule,
TextSpinnerComponentModule,
EmverPipesModule,
MarkdownPipeModule,
TuiElementModule,
],
declarations: [ReleaseNotesComponent],
exports: [ReleaseNotesComponent],
})
export class ReleaseNotesModule {}

View File

@@ -0,0 +1,29 @@
<!-- release notes -->
<ion-item-divider>
New in {{ pkg.manifest.version | displayEmver }}
</ion-item-divider>
<ion-item lines="none" color="transparent">
<ion-label>
<div [innerHTML]="pkg.manifest['release-notes'] | markdown"></div>
</ion-label>
</ion-item>
<ion-button routerLink="notes" fill="clear" strong>
Past Release Notes
<ion-icon slot="end" name="arrow-forward"></ion-icon>
</ion-button>
<!-- description -->
<ion-item-divider>Description</ion-item-divider>
<ion-item lines="none" color="transparent">
<ion-label>
<h2>{{ pkg.manifest.description.long }}</h2>
</ion-label>
</ion-item>
<div
*ngIf="pkg.manifest['marketing-site'] as url"
style="padding: 4px 0 10px 14px"
>
<ion-button [href]="url" target="_blank" rel="noreferrer" color="tertiary">
View website
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</div>

View File

@@ -0,0 +1,4 @@
.all-notes {
position: absolute;
right: 10px;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-about',
templateUrl: 'about.component.html',
styleUrls: ['about.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AboutComponent {
@Input()
pkg!: MarketplacePkg
}

View File

@@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { EmverPipesModule, MarkdownPipeModule } from '@start9labs/shared'
import { AboutComponent } from './about.component'
@NgModule({
imports: [
CommonModule,
RouterModule,
IonicModule,
MarkdownPipeModule,
EmverPipesModule,
],
declarations: [AboutComponent],
exports: [AboutComponent],
})
export class AboutModule {}

View File

@@ -0,0 +1,106 @@
<ng-container *ngIf="pkg.manifest.replaces as replaces">
<div *ngIf="replaces.length" class="ion-padding-bottom">
<ion-item-divider>Intended to replace</ion-item-divider>
<ul>
<li *ngFor="let app of replaces">
{{ app }}
</li>
</ul>
</div>
</ng-container>
<ion-item-divider>Additional Info</ion-item-divider>
<ion-grid *ngIf="pkg.manifest as manifest">
<ion-row>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
*ngIf="manifest['git-hash'] as gitHash; else noHash"
button
detail="false"
(click)="copy(gitHash)"
>
<ion-label>
<h2>Git Hash</h2>
<p>{{ gitHash }}</p>
</ion-label>
<ion-icon slot="end" name="copy-outline"></ion-icon>
</ion-item>
<ng-template #noHash>
<ion-item>
<ion-label>
<h2>Git Hash</h2>
<p>Unknown</p>
</ion-label>
</ion-item>
</ng-template>
<ion-item button detail="false" (click)="presentAlertVersions()">
<ion-label>
<h2>Other Versions</h2>
<p>Click to view other versions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item button detail="false" (click)="presentModalMd('license')">
<ion-label>
<h2>License</h2>
<p>{{ manifest.license }}</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item
button
detail="false"
(click)="presentModalMd('instructions')"
>
<ion-label>
<h2>Instructions</h2>
<p>Click to view instructions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
[href]="manifest['upstream-repo']"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Source Repository</h2>
<p>{{ manifest['upstream-repo'] }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="manifest['wrapper-repo']"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Wrapper Repository</h2>
<p>{{ manifest['wrapper-repo'] }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="manifest['support-site']"
[disabled]="!manifest['support-site']"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Support Site</h2>
<p>{{ manifest['support-site'] || 'Not provided' }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,101 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
import {
AlertController,
ModalController,
ToastController,
} from '@ionic/angular'
import {
copyToClipboard,
displayEmver,
Emver,
MarkdownComponent,
} from '@start9labs/shared'
import { MarketplacePkg } from '../../../types'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
import { ActivatedRoute } from '@angular/router'
@Component({
selector: 'marketplace-additional',
templateUrl: 'additional.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdditionalComponent {
@Input()
pkg!: MarketplacePkg
@Output()
version = new EventEmitter<string>()
readonly url = this.route.snapshot.queryParamMap.get('url') || undefined
constructor(
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly emver: Emver,
private readonly marketplaceService: AbstractMarketplaceService,
private readonly toastCtrl: ToastController,
private readonly route: ActivatedRoute,
) {}
async copy(address: string): Promise<void> {
const success = await copyToClipboard(address)
const 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 presentAlertVersions() {
const alert = await this.alertCtrl.create({
header: 'Versions',
inputs: this.pkg.versions
.sort((a, b) => -1 * (this.emver.compare(a, b) || 0))
.map(v => ({
name: v, // for CSS
type: 'radio',
label: displayEmver(v), // appearance on screen
value: v, // literal SEM version value
checked: this.pkg.manifest.version === v,
})),
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Ok',
handler: (version: string) => this.version.emit(version),
},
],
})
await alert.present()
}
async presentModalMd(title: string) {
const content = this.marketplaceService.fetchStatic$(
this.pkg.manifest.id,
title,
this.url,
)
const modal = await this.modalCtrl.create({
componentProps: { title, content },
component: MarkdownComponent,
})
await modal.present()
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { MarkdownModule, ResponsiveColModule } from '@start9labs/shared'
import { AdditionalComponent } from './additional.component'
@NgModule({
imports: [CommonModule, IonicModule, MarkdownModule, ResponsiveColModule],
declarations: [AdditionalComponent],
exports: [AdditionalComponent],
})
export class AdditionalModule {}

View File

@@ -0,0 +1,35 @@
<ion-item-divider>Dependencies</ion-item-divider>
<ion-grid>
<ion-row>
<ion-col
*ngFor="let dep of pkg.manifest.dependencies | keyvalue"
responsiveCol
sizeSm="12"
sizeMd="6"
>
<ion-item [routerLink]="['/marketplace', dep.key]">
<ion-thumbnail slot="start">
<img
alt=""
style="border-radius: 100%"
[src]="getImg(dep.key) | trustUrl"
/>
</ion-thumbnail>
<ion-label>
<h2>
{{ pkg['dependency-metadata'][dep.key].title }}
<ng-container [ngSwitch]="dep.value.requirement.type">
<span *ngSwitchCase="'required'">(required)</span>
<span *ngSwitchCase="'opt-out'">(required by default)</span>
<span *ngSwitchCase="'opt-in'">(optional)</span>
</ng-container>
</h2>
<p>
<small>{{ dep.value.version | displayEmver }}</small>
</p>
<p>{{ dep.value.description }}</p>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-dependencies',
templateUrl: 'dependencies.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DependenciesComponent {
@Input()
pkg!: MarketplacePkg
getImg(key: string): string {
// @TODO fix when registry api is updated to include mimetype in icon url
return 'data:image/png;base64,' + this.pkg['dependency-metadata'][key].icon
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<div class="header montserrat">
<img class="logo" alt="" [src]="pkg | mimeType | trustUrl" />
<div class="text">
<h1 ticker class="title">{{ pkg.manifest.title }}</h1>
<p class="version">{{ pkg.manifest.version | displayEmver }}</p>
<p class="published">
Released: {{ pkg['published-at'] | date: 'medium' }}
</p>
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,47 @@
.header {
display: flex;
align-items: flex-start;
padding: 16px;
}
.text {
display: inline-block;
vertical-align: top;
overflow: hidden;
}
.logo {
width: 80px;
margin-right: 16px;
border-radius: 100%;
}
.title {
margin: 0 0 0 -2px;
font-size: 36px;
}
.version {
margin: 0;
font-size: 18px;
}
.published {
margin: 0;
padding: 4px 0 12px 0;
font-style: italic;
}
@media (min-width: 1000px) {
.logo {
width: 140px;
}
.title {
font-size: 64px;
}
.version {
font-size: 32px;
}
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-package',
templateUrl: 'package.component.html',
styleUrls: ['package.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PackageComponent {
@Input()
pkg!: MarketplacePkg
}

View File

@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import {
EmverPipesModule,
SharedPipesModule,
TickerModule,
} from '@start9labs/shared'
import { PackageComponent } from './package.component'
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
@NgModule({
declarations: [PackageComponent],
exports: [PackageComponent],
imports: [
CommonModule,
IonicModule,
SharedPipesModule,
EmverPipesModule,
TickerModule,
MimeTypePipeModule,
],
})
export class PackageModule {}

View File

@@ -0,0 +1,85 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core'
import { MarketplacePkg } from '../types'
import Fuse from 'fuse.js'
@Pipe({
name: 'filterPackages',
})
export class FilterPackagesPipe implements PipeTransform {
transform(
packages: MarketplacePkg[],
query: string,
category: string,
): MarketplacePkg[] {
// query
if (query) {
let options: Fuse.IFuseOptions<MarketplacePkg> = {
includeScore: true,
includeMatches: true,
}
if (query.length < 4) {
options = {
...options,
threshold: 0.2,
location: 0,
distance: 16,
keys: [
{
name: 'manifest.title',
weight: 1,
},
{
name: 'manifest.id',
weight: 0.5,
},
],
}
} else {
options = {
...options,
ignoreLocation: true,
useExtendedSearch: true,
keys: [
{
name: 'manifest.title',
weight: 1,
},
{
name: 'manifest.id',
weight: 0.5,
},
{
name: 'manifest.description.short',
weight: 0.4,
},
{
name: 'manifest.description.long',
weight: 0.1,
},
],
}
query = `'${query}`
}
const fuse = new Fuse(packages, options)
return fuse.search(query).map(p => p.item)
}
// category
return packages
.filter(p => category === 'all' || p.categories.includes(category))
.sort((a, b) => {
return (
new Date(b['published-at']).valueOf() -
new Date(a['published-at']).valueOf()
)
})
}
}
@NgModule({
declarations: [FilterPackagesPipe],
exports: [FilterPackagesPipe],
})
export class FilterPackagesPipeModule {}

View File

@@ -0,0 +1,32 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core'
import { MarketplacePkg } from '../types'
@Pipe({
name: 'mimeType',
})
export class MimeTypePipe implements PipeTransform {
transform(pkg: MarketplacePkg): string {
if (pkg.manifest.assets.icon) {
switch (pkg.manifest.assets.icon.split('.').pop()) {
case 'png':
return `data:image/png;base64,${pkg.icon}`
case 'jpeg':
case 'jpg':
return `data:image/jpeg;base64,${pkg.icon}`
case 'gif':
return `data:image/gif;base64,${pkg.icon}`
case 'svg':
return `data:image/svg+xml;base64,${pkg.icon}`
default:
return `data:image/png;base64,${pkg.icon}`
}
}
return `data:image/png;base64,${pkg.icon}`
}
}
@NgModule({
declarations: [MimeTypePipe],
exports: [MimeTypePipe],
})
export class MimeTypePipeModule {}

View File

@@ -0,0 +1,29 @@
/*
* Public API Surface of @start9labs/marketplace
*/
export * from './pages/list/categories/categories.component'
export * from './pages/list/categories/categories.module'
export * from './pages/list/item/item.component'
export * from './pages/list/item/item.module'
export * from './pages/list/search/search.component'
export * from './pages/list/search/search.module'
export * from './pages/list/skeleton/skeleton.component'
export * from './pages/list/skeleton/skeleton.module'
export * from './pages/release-notes/release-notes.component'
export * from './pages/release-notes/release-notes.module'
export * from './pages/show/about/about.component'
export * from './pages/show/about/about.module'
export * from './pages/show/additional/additional.component'
export * from './pages/show/additional/additional.module'
export * from './pages/show/dependencies/dependencies.component'
export * from './pages/show/dependencies/dependencies.module'
export * from './pages/show/package/package.component'
export * from './pages/show/package/package.module'
export * from './pipes/filter-packages.pipe'
export * from './pipes/mime-type.pipe'
export * from './services/marketplace.service'
export * from './types'

View File

@@ -0,0 +1,29 @@
import { Observable } from 'rxjs'
import { MarketplacePkg, Marketplace, StoreData, StoreIdentity } from '../types'
export abstract class AbstractMarketplaceService {
abstract getKnownHosts$(): Observable<StoreIdentity[]>
abstract getSelectedHost$(): Observable<StoreIdentity>
abstract getMarketplace$(): Observable<Marketplace>
abstract getSelectedStore$(): Observable<StoreData>
abstract getPackage$(
id: string,
version: string,
url?: string,
): Observable<MarketplacePkg> // could be {} so need to check in show page
abstract fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>>
abstract fetchStatic$(
id: string,
type: string,
url?: string,
): Observable<string>
}

View File

@@ -0,0 +1,87 @@
import { Url } from '@start9labs/shared'
export type StoreURL = string
export type StoreName = string
export interface StoreIdentity {
url: StoreURL
name?: StoreName
}
export type Marketplace = Record<StoreURL, StoreData | null>
export interface StoreData {
info: StoreInfo
packages: MarketplacePkg[]
}
export interface StoreInfo {
name: StoreName
categories: string[]
}
export interface MarketplacePkg {
icon: Url
license: Url
instructions: Url
manifest: MarketplaceManifest
categories: string[]
versions: string[]
'dependency-metadata': {
[id: string]: DependencyMetadata
}
'published-at': string
}
export interface DependencyMetadata {
title: string
icon: Url
hidden: boolean
}
export interface MarketplaceManifest<T = unknown> {
id: string
title: string
version: string
'git-hash'?: string
description: {
short: string
long: string
}
assets: {
icon: string // ie. icon.png
}
replaces?: string[]
'release-notes': string
license: string // type of license
'wrapper-repo': Url
'upstream-repo': Url
'support-site': Url
'marketing-site': Url
'donation-url': Url | null
alerts: {
install: string | null
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
dependencies: Record<string, Dependency<T>>
}
export interface Dependency<T> {
version: string
requirement:
| {
type: 'opt-in'
how: string
}
| {
type: 'opt-out'
how: string
}
| {
type: 'required'
}
description: string | null
config: T
}

View File

@@ -0,0 +1,13 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": ["src/test.ts", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,56 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{
path: 'home',
loadChildren: () =>
import('./pages/home/home.module').then(m => m.HomePageModule),
},
{
path: 'attach',
loadChildren: () =>
import('./pages/attach/attach.module').then(m => m.AttachPageModule),
},
{
path: 'recover',
loadChildren: () =>
import('./pages/recover/recover.module').then(m => m.RecoverPageModule),
},
{
path: 'transfer',
loadChildren: () =>
import('./pages/transfer/transfer.module').then(
m => m.TransferPageModule,
),
},
{
path: 'storage',
loadChildren: () =>
import('./pages/embassy/embassy.module').then(m => m.EmbassyPageModule),
},
{
path: 'loading',
loadChildren: () =>
import('./pages/loading/loading.module').then(m => m.LoadingPageModule),
},
{
path: 'success',
loadChildren: () =>
import('./pages/success/success.module').then(m => m.SuccessPageModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
initialNavigation: 'disabled',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,5 @@
<tui-root>
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
</tui-root>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}

View File

@@ -0,0 +1,32 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { ApiService } from './services/api/api.service'
import { ErrorToastService } from '@start9labs/shared'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor(
private readonly apiService: ApiService,
private readonly errorToastService: ErrorToastService,
private readonly navCtrl: NavController,
) {}
async ngOnInit() {
try {
const inProgress = await this.apiService.getStatus()
let route = '/home'
if (inProgress) {
route = inProgress.complete ? '/success' : '/loading'
}
await this.navCtrl.navigateForward(route)
} catch (e: any) {
this.errorToastService.present(e)
}
}
}

View File

@@ -0,0 +1,58 @@
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { HttpClientModule } from '@angular/common/http'
import { TuiRootModule } from '@taiga-ui/core'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import {
IonicModule,
IonicRouteStrategy,
iosTransitionAnimation,
} from '@ionic/angular'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { SuccessPageModule } from './pages/success/success.module'
import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module'
import { RecoverPageModule } from './pages/recover/recover.module'
import { TransferPageModule } from './pages/transfer/transfer.module'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
imports: [
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
navAnimation: iosTransitionAnimation,
}),
AppRoutingModule,
HttpClientModule,
SuccessPageModule,
HomePageModule,
LoadingPageModule,
RecoverPageModule,
TransferPageModule,
TuiRootModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { CifsModal } from './cifs-modal.page'
@NgModule({
declarations: [CifsModal],
imports: [CommonModule, FormsModule, IonicModule],
exports: [CifsModal],
})
export class CifsModalModule {}

View File

@@ -0,0 +1,93 @@
<ion-header>
<ion-toolbar>
<ion-title>Connect Network Folder</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form (ngSubmit)="submit()" #cifsForm="ngForm">
<p>Hostname *</p>
<ion-item>
<ion-input
id="hostname"
required
[(ngModel)]="cifs.hostname"
name="hostname"
#hostname="ngModel"
placeholder="e.g. 'My Computer' OR 'my-computer.local'"
pattern="^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$"
></ion-input>
</ion-item>
<p [hidden]="hostname.valid || hostname.pristine">
<ion-text color="danger">
Hostname is required. e.g. 'My Computer' OR 'my-computer.local'
</ion-text>
</p>
<p>Path *</p>
<ion-item>
<ion-input
id="path"
required
[(ngModel)]="cifs.path"
name="path"
#path="ngModel"
placeholder="ex. /Desktop/my-folder'"
></ion-input>
</ion-item>
<p [hidden]="path.valid || path.pristine">
<ion-text color="danger">Path is required</ion-text>
</p>
<p>Username *</p>
<ion-item>
<ion-input
id="username"
required
[(ngModel)]="cifs.username"
name="username"
#username="ngModel"
placeholder="Enter username"
></ion-input>
</ion-item>
<p [hidden]="username.valid || username.pristine">
<ion-text color="danger">Username is required</ion-text>
</p>
<p>Password</p>
<ion-item>
<ion-input
id="password"
type="password"
[(ngModel)]="cifs.password"
name="password"
#password="ngModel"
></ion-input>
</ion-item>
<button hidden type="submit"></button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button
class="ion-padding-end"
slot="end"
color="warning"
(click)="cancel()"
>
Cancel
</ion-button>
<ion-button
class="ion-padding-end"
slot="end"
color="primary"
strong="true"
[disabled]="!cifsForm.form.valid"
(click)="submit()"
>
Verify
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,20 @@
.item-interactive {
--highlight-background: var(--ion-color-dark) !important;
}
ion-item {
&:hover {
transition-property: transform;
transform: none;
}
}
.item-has-focus {
--background: var(--ion-color-dark-tint) !important;
}
ion-modal {
--backdrop-opacity: 0.7;
}

View File

@@ -0,0 +1,94 @@
import { Component } from '@angular/core'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ApiService, CifsBackupTarget } from 'src/app/services/api/api.service'
import { StartOSDiskInfo } from '@start9labs/shared'
import { PasswordPage } from '../password/password.page'
@Component({
selector: 'cifs-modal',
templateUrl: 'cifs-modal.page.html',
styleUrls: ['cifs-modal.page.scss'],
})
export class CifsModal {
cifs = {
type: 'cifs' as 'cifs',
hostname: '',
path: '',
username: '',
password: '',
}
constructor(
private readonly modalController: ModalController,
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
) {}
cancel() {
this.modalController.dismiss()
}
async submit(): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Connecting to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const diskInfo = await this.api.verifyCifs({
...this.cifs,
password: this.cifs.password
? await this.api.encrypt(this.cifs.password)
: null,
})
await loader.dismiss()
this.presentModalPassword(diskInfo)
} catch (e) {
await loader.dismiss()
this.presentAlertFailed()
}
}
private async presentModalPassword(diskInfo: StartOSDiskInfo): Promise<void> {
const target: CifsBackupTarget = {
...this.cifs,
mountable: true,
'embassy-os': diskInfo,
}
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { target },
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalController.dismiss(
{
cifs: this.cifs,
recoveryPassword: res.data.password,
},
'success',
)
}
})
await modal.present()
}
private async presentAlertFailed(): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Connection Failed',
message:
'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
buttons: ['OK'],
})
alert.present()
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { PasswordPage } from './password.page'
@NgModule({
declarations: [PasswordPage],
imports: [CommonModule, FormsModule, IonicModule],
exports: [PasswordPage],
})
export class PasswordPageModule {}

View File

@@ -0,0 +1,89 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ storageDrive ? 'Set Password' : 'Unlock Drive' }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div style="padding: 8px 24px">
<p *ngIf="!storageDrive else choose">
Enter the password that was used to encrypt this drive.
</p>
<ng-template #choose>
<p>
Choose a password for your server.
<i>Make it good. Write it down.</i>
</p>
</ng-template>
<form (ngSubmit)="storageDrive ? submitPw() : verifyPw()">
<ion-item
[class]="pwError ? 'error-border' : password && storageDrive ? 'success-border' : ''"
>
<ion-input
#focusInput
[(ngModel)]="password"
[ngModelOptions]="{'standalone': true}"
[type]="!unmasked1 ? 'password' : 'text'"
placeholder="Enter Password"
(ionChange)="validate()"
></ion-input>
<ion-button fill="clear" color="light" (click)="unmasked1 = !unmasked1">
<ion-icon
slot="icon-only"
[name]="unmasked1 ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
</ion-item>
<p *ngIf="pwError" class="error-message">{{ pwError }}</p>
<ng-container *ngIf="storageDrive">
<ion-item
[class]="verError ? 'error-border' : passwordVer ? 'success-border' : ''"
>
<ion-input
[(ngModel)]="passwordVer"
[ngModelOptions]="{'standalone': true}"
[type]="!unmasked2 ? 'password' : 'text'"
placeholder="Retype Password"
(ionChange)="checkVer()"
></ion-input>
<ion-button
fill="clear"
color="light"
(click)="unmasked2 = !unmasked2"
>
<ion-icon
slot="icon-only"
[name]="unmasked2 ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
</ion-item>
<p *ngIf="verError" class="error-message">{{ verError }}</p>
</ng-container>
<input type="submit" style="display: none" />
</form>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button
class="ion-padding-end"
slot="end"
color="warning"
(click)="cancel()"
>
Cancel
</ion-button>
<ion-button
class="ion-padding-end"
slot="end"
strong="true"
(click)="storageDrive ? submitPw() : verifyPw()"
>
{{ storageDrive ? 'Finish' : 'Unlock' }}
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,21 @@
.item-interactive {
--highlight-background: var(--ion-color-dark) !important;
}
ion-item {
&:hover {
transition-property: transform;
transform: none;
}
}
.item-has-focus {
--background: var(--ion-color-dark-tint) !important;
}
.error-message {
color: var(--ion-color-danger) !important;
font-size: .9rem !important;
margin-left: 36px;
margin-top: -16px;
}

View File

@@ -0,0 +1,81 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, ModalController } from '@ionic/angular'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.service'
import * as argon2 from '@start9labs/argon2'
@Component({
selector: 'app-password',
templateUrl: 'password.page.html',
styleUrls: ['password.page.scss'],
})
export class PasswordPage {
@ViewChild('focusInput') elem?: IonInput
@Input() target?: CifsBackupTarget | DiskBackupTarget
@Input() storageDrive = false
pwError = ''
password = ''
unmasked1 = false
verError = ''
passwordVer = ''
unmasked2 = false
constructor(private modalController: ModalController) {}
ngAfterViewInit() {
setTimeout(() => this.elem?.setFocus(), 400)
}
async verifyPw() {
if (!this.target || !this.target['embassy-os'])
this.pwError = 'No recovery target' // unreachable
try {
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, this.password)
this.modalController.dismiss({ password: this.password }, 'success')
} catch (e) {
this.pwError = 'Incorrect password provided'
}
}
async submitPw() {
this.validate()
if (this.password !== this.passwordVer) {
this.verError = '*passwords do not match'
}
if (this.pwError || this.verError) return
this.modalController.dismiss({ password: this.password }, 'success')
}
validate() {
if (!!this.target) return (this.pwError = '')
if (this.passwordVer) {
this.checkVer()
}
if (this.password.length < 12) {
this.pwError = 'Must be 12 characters or greater'
} else if (this.password.length > 64) {
this.pwError = 'Must be less than 65 characters'
} else {
this.pwError = ''
}
}
checkVer() {
this.verError =
this.password !== this.passwordVer ? 'Passwords do not match' : ''
}
cancel() {
this.modalController.dismiss()
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { AttachPage } from './attach.page'
const routes: Routes = [
{
path: '',
component: AttachPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AttachPageRoutingModule {}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
GuidPipePipesModule,
UnitConversionPipesModule,
} from '@start9labs/shared'
import { AttachPage } from './attach.page'
import { AttachPageRoutingModule } from './attach-routing.module'
@NgModule({
declarations: [AttachPage],
imports: [
CommonModule,
IonicModule,
AttachPageRoutingModule,
UnitConversionPipesModule,
GuidPipePipesModule,
],
})
export class AttachPageModule {}

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