init
This commit is contained in:
41
client/src/app/app.module.ts
Normal file
41
client/src/app/app.module.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {HttpClientModule} from '@angular/common/http';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
import {NgChartsModule} from 'ng2-charts';
|
||||
import {LibraryFormComponent} from './components/library-form/library-form.component';
|
||||
import {LibrarySelectorComponent} from './components/library-selector/library-selector.component';
|
||||
import {PiechartComponent} from './components/piechart/piechart.component';
|
||||
import {SettingsFormComponent} from './components/settings-form/settings-form.component';
|
||||
import {ToolbarComponent} from './components/toolbar/toolbar.component';
|
||||
import {FormHelperModule} from './modules/form-helper';
|
||||
import {SizePipe} from './pipes/size.pipe';
|
||||
|
||||
import {AppComponent} from './views/app.component';
|
||||
import {MaterialModule} from './modules/material.module';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
LibraryFormComponent,
|
||||
LibrarySelectorComponent,
|
||||
PiechartComponent,
|
||||
SettingsFormComponent,
|
||||
SizePipe,
|
||||
ToolbarComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule.withServerTransition({appId: 'serverApp'}),
|
||||
BrowserAnimationsModule,
|
||||
FormHelperModule,
|
||||
FormsModule,
|
||||
HttpClientModule,
|
||||
MaterialModule,
|
||||
NgChartsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule {}
|
@ -0,0 +1,13 @@
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field class="w-50">
|
||||
<mat-label>Name</mat-label>
|
||||
<input formControlName="name" matInput type="text">
|
||||
<mat-error *ngIf="getError('name') as error">{{error.key}}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-checkbox formControlName="watch" class="ms-3">Watch for changes</mat-checkbox>
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Directory</mat-label>
|
||||
<input formControlName="path" matInput type="text">
|
||||
<mat-error *ngIf="getError('path') as error">{{error.key}}</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
@ -0,0 +1,29 @@
|
||||
import {Component, forwardRef} from '@angular/core';
|
||||
import {FormBuilder, NG_VALUE_ACCESSOR, Validators} from '@angular/forms';
|
||||
import {Library} from '@transmute/common';
|
||||
import {FormBoilerplateComponent} from '../../modules/form-helper';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-library-form',
|
||||
templateUrl: './library-form.component.html',
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => LibraryFormComponent),
|
||||
multi: true,
|
||||
}],
|
||||
})
|
||||
export class LibraryFormComponent extends FormBoilerplateComponent<Library> {
|
||||
constructor(fb: FormBuilder) {
|
||||
super(fb.group({
|
||||
name: ['', [Validators.required]],
|
||||
path: ['', [Validators.required]],
|
||||
watch: [true],
|
||||
}));
|
||||
}
|
||||
|
||||
resetHook(preventDefault: () => void) { }
|
||||
|
||||
validateHook(value: Library, preventDefault: () => void): void | boolean { }
|
||||
|
||||
writeHook(value: Library, preventDefault: () => void): void { }
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<mat-form-field appearance="outline" class="me-3">
|
||||
<mat-label>Library</mat-label>
|
||||
<mat-select [value]="selected" (valueChange)="selectionChanged($event)">
|
||||
<mat-option class="fw-bold" value="">All</mat-option>
|
||||
<mat-option *ngFor="let l of libraries" [value]="l">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
{{l.name}}
|
||||
</div>
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button *ngIf="selected" mat-mini-fab color="primary" [matMenuTriggerFor]="menu" class="mb-4">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<button *ngIf="!selected" mat-mini-fab color="primary" (click)="scan(!selected ? null : selected)" class="mb-4" matTooltip="Scan">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
</button>
|
||||
<button mat-mini-fab color="accent" (click)="edit()" matTooltip="New" class="ms-2 mb-4">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
<button mat-menu-item (click)="scan(!selected ? null : selected)">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
<span>Scan</span>
|
||||
</button>
|
||||
<button *ngIf="selected" mat-menu-item (click)="edit(selected)">
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button *ngIf="selected" mat-menu-item (click)="delete(selected)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
@ -0,0 +1,61 @@
|
||||
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from '@angular/core';
|
||||
import {Library} from '@transmute/common';
|
||||
import {FormHelperService} from '../../modules/form-helper/services/form-helper.service';
|
||||
import {LibraryClient} from '../../services/library.service';
|
||||
import {LibraryFormComponent} from '../library-form/library-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-library-selector',
|
||||
templateUrl: './library-selector.component.html'
|
||||
})
|
||||
export class LibrarySelectorComponent implements OnChanges {
|
||||
libraries: Library[] = [];
|
||||
|
||||
@Input() selected?: Library | '';
|
||||
@Output() selectedChange = new EventEmitter();
|
||||
|
||||
constructor(private fh: FormHelperService,
|
||||
private libraryApi: LibraryClient
|
||||
) {
|
||||
this.libraryApi.listen().subscribe(l => this.libraries = l);
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if(changes['selected'] && !this.selected) this.selected = <any>'';
|
||||
}
|
||||
|
||||
async delete(library: Library) {
|
||||
if(!(await this.fh.confirm('Delete',
|
||||
`Are you sure you want to delete: ${library.name}`
|
||||
))) return;
|
||||
await this.libraryApi.delete(library);
|
||||
this.selectionChanged(null);
|
||||
}
|
||||
|
||||
edit(library?: Library) {
|
||||
console.log(library);
|
||||
this.fh.form<Library>(LibraryFormComponent, (value, close, banner) => {
|
||||
if(!value) return;
|
||||
const create = value['id'] == undefined;
|
||||
this.libraryApi[create ? 'create' : 'update'](value).then(saved => {
|
||||
const exists = this.libraries.findIndex(l => l.id == saved.id);
|
||||
if(exists == -1) this.libraries.push(saved);
|
||||
else this.libraries.splice(exists, 1, saved);
|
||||
this.selectionChanged(saved);
|
||||
close();
|
||||
}).catch(resp => banner(resp.error.message));
|
||||
}, {
|
||||
title: `${library ? 'Edit' : 'New'} Library`,
|
||||
value: library
|
||||
});
|
||||
}
|
||||
|
||||
scan(library: Library | null) {
|
||||
this.libraryApi.api.scan(library?.id);
|
||||
}
|
||||
|
||||
selectionChanged(library: Library | null) {
|
||||
this.selected = library || '';
|
||||
this.selectedChange.emit(library);
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
<h2>{{title}}</h2>
|
||||
<canvas baseChart
|
||||
[data]="dataset"
|
||||
[options]="options"
|
||||
type="doughnut">
|
||||
</canvas>
|
29
client/src/app/components/piechart/piechart.component.ts
Normal file
29
client/src/app/components/piechart/piechart.component.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-pichart',
|
||||
templateUrl: './piechart.component.html'
|
||||
})
|
||||
export class PiechartComponent implements OnChanges {
|
||||
@Input() data!: {[key: string]: number};
|
||||
@Input() title?: string;
|
||||
|
||||
dataset!: {labels: string[], datasets: {data: number[]}[]};
|
||||
options: any = {
|
||||
plugins: {
|
||||
legend: {position: 'bottom'}
|
||||
}
|
||||
};
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if(changes['title']) this.options.plugins.title = {text: this.title};
|
||||
if(changes['data']) {
|
||||
const labels: string[] = [], data: number[] = [];
|
||||
Object.entries(this.data).forEach(([key, value]) => {
|
||||
labels.push(key);
|
||||
data.push(value);
|
||||
});
|
||||
this.dataset = {labels, datasets: [{data}]};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,106 @@
|
||||
<div class="mb-2" [formGroup]="form">
|
||||
<h2>General</h2>
|
||||
<mat-form-field>
|
||||
<mat-label>Job Priority</mat-label>
|
||||
<mat-select formControlName="priority">
|
||||
<mat-option value="">Auto</mat-option>
|
||||
<mat-option value="healthcheck">Healthcheck</mat-option>
|
||||
<mat-option value="transcode">Transcode</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [formGroup]="form">
|
||||
<h2>Healthchecks</h2>
|
||||
<mat-radio-group formControlName="healthcheck">
|
||||
<mat-radio-button class="mb-3 me-2" value="">Disable</mat-radio-button>
|
||||
<mat-radio-button class="mb-3 me-2" value="quick">Quick</mat-radio-button>
|
||||
<mat-radio-button class="mb-3 me-2" value="frame-by-frame">Frame-by-Frame</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
<div>
|
||||
<mat-checkbox formControlName="deleteUnhealthy">
|
||||
Delete unhealthy files
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2" [formGroup]="form">
|
||||
<h2>Transcoding</h2>
|
||||
<div>
|
||||
<mat-checkbox formControlName="healthyOnly" class="mb-3 me-2">Only transcode healthy files</mat-checkbox>
|
||||
<mat-checkbox formControlName="deleteOriginal" class="mb-3 me-2">Delete after transcoding</mat-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Container</mat-label>
|
||||
<mat-select formControlName="targetContainer">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let c of containers" [value]="c[0]">{{c[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Video Codec</mat-label>
|
||||
<mat-select formControlName="targetVideoCodec">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let v of videoCodecs" [value]="v[0]">{{v[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field style="width: 175px">
|
||||
<mat-label>Audio Codec</mat-label>
|
||||
<mat-select formControlName="targetAudioCodec">
|
||||
<mat-option value="">Don't Change</mat-option>
|
||||
<mat-option *ngFor="let a of audioCodecs" [value]="a[0]">{{a[1]}}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-checkbox class="mb-3 me-3" [(ngModel)]="sizeCutoff" [ngModelOptions]="{standalone: true}">
|
||||
Stop transcoding if larger than
|
||||
</mat-checkbox>
|
||||
<mat-form-field style="width: 100px" floatLabel="always">
|
||||
<mat-label>Original</mat-label>
|
||||
<input class="text-end hide-incrementor" matInput type="number" [disabled]="!sizeCutoff">
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" [formGroup]="form">
|
||||
<h2>Audio</h2>
|
||||
<mat-checkbox [(ngModel)]="audioFilter" [ngModelOptions]="{standalone: true}">
|
||||
Filter Audio
|
||||
</mat-checkbox>
|
||||
<div *ngIf="audioFilter">
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Languages</mat-label>
|
||||
<!-- <mat-chip-grid #aLangChips class="d-inline-flex">-->
|
||||
<!-- <mat-chip-row *ngFor="let l of aLangs; let i = index" (removed)="removeLang(aLangs, i)">-->
|
||||
<!-- {{l}} <button matChipRemove><mat-icon>cancel</mat-icon></button>-->
|
||||
<!-- </mat-chip-row>-->
|
||||
<!-- </mat-chip-grid>-->
|
||||
<!-- <input class="d-inline" style="width: auto" [matChipInputFor]="aLangChips" (matChipInputTokenEnd)="addLang(aLangs, $event)"/>-->
|
||||
<input matInput>
|
||||
<mat-hint>
|
||||
<span class="fw-bold">Warning:</span> Leaving blank will remove all audio tracks
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" [formGroup]="form">
|
||||
<h2>Subtitles</h2>
|
||||
<mat-checkbox [(ngModel)]="subFilter" [ngModelOptions]="{standalone: true}">
|
||||
Filter Subtitles
|
||||
</mat-checkbox>
|
||||
<div *ngIf="subFilter">
|
||||
<mat-form-field class="w-100">
|
||||
<mat-label>Languages</mat-label>
|
||||
<!-- <mat-chip-grid #aLangChips class="d-inline-flex">-->
|
||||
<!-- <mat-chip-row *ngFor="let l of aLangs; let i = index" (removed)="removeLang(aLangs, i)">-->
|
||||
<!-- {{l}} <button matChipRemove><mat-icon>cancel</mat-icon></button>-->
|
||||
<!-- </mat-chip-row>-->
|
||||
<!-- </mat-chip-grid>-->
|
||||
<!-- <input class="d-inline" style="width: auto" [matChipInputFor]="aLangChips" (matChipInputTokenEnd)="addLang(aLangs, $event)"/>-->
|
||||
<input matInput>
|
||||
<mat-hint>
|
||||
<span class="fw-bold">Warning:</span> Leaving blank will remove all subtitles
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,52 @@
|
||||
import {Component, forwardRef} from '@angular/core';
|
||||
import {FormBuilder, NG_VALUE_ACCESSOR} from '@angular/forms';
|
||||
import {AudioCodec, Config, Container, VideoCodec} from '@transmute/common';
|
||||
import {FormBoilerplateComponent} from '../../modules/form-helper';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-settings-form',
|
||||
templateUrl: './settings-form.component.html',
|
||||
providers: [{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SettingsFormComponent),
|
||||
multi: true,
|
||||
}],
|
||||
})
|
||||
export class SettingsFormComponent extends FormBoilerplateComponent<Config> {
|
||||
containers = Object.entries(Container);
|
||||
videoCodecs = Object.entries(VideoCodec);
|
||||
audioCodecs = Object.entries(AudioCodec);
|
||||
audioFilter = false;
|
||||
sizeCutoff = false;
|
||||
subFilter = false;
|
||||
|
||||
constructor(fb: FormBuilder) {
|
||||
super(fb.group({
|
||||
priority: ['', []],
|
||||
healthcheck: ['', []],
|
||||
deleteUnhealthy: [false, []],
|
||||
healthyOnly: [false, []],
|
||||
deleteOriginal: [false, []],
|
||||
targetContainer: ['', []],
|
||||
targetVideoCodec: ['', []],
|
||||
targetAudioCodec: ['', []],
|
||||
singleAudioTrack: [false, []],
|
||||
audioTracks: [[], []],
|
||||
subTracks: [[], []],
|
||||
}));
|
||||
|
||||
this.form.controls['deleteUnhealthy'].disable();
|
||||
this.form.controls['healthyOnly'].disable();
|
||||
this.form.controls['healthcheck'].valueChanges.subscribe(v => {
|
||||
this.form.controls['deleteUnhealthy'][!!v ? 'enable' : 'disable']();
|
||||
this.form.controls['healthyOnly'][!!v ? 'enable' : 'disable']();
|
||||
if(!v) this.writeValue({deleteUnhealthy: false, onlyTranscodeHealthy: false});
|
||||
});
|
||||
}
|
||||
|
||||
resetHook(preventDefault: () => void) { }
|
||||
|
||||
validateHook(value: Config, preventDefault: () => void): void | boolean { }
|
||||
|
||||
writeHook(value: Config, preventDefault: () => void): void { }
|
||||
}
|
14
client/src/app/components/toolbar/toolbar.component.html
Normal file
14
client/src/app/components/toolbar/toolbar.component.html
Normal file
@ -0,0 +1,14 @@
|
||||
<mat-toolbar style="height: 50px; min-height: 50px">
|
||||
<mat-toolbar-row class="px-3" style="height: 50px">
|
||||
<div class="d-flex align-items-center">
|
||||
<mat-icon>rotate_right</mat-icon>
|
||||
Transmute
|
||||
</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button mat-icon-button (click)="settings()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
19
client/src/app/components/toolbar/toolbar.component.ts
Normal file
19
client/src/app/components/toolbar/toolbar.component.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {FormHelperService} from '../../modules/form-helper/services/form-helper.service';
|
||||
import {SettingsFormComponent} from '../settings-form/settings-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'tm-toolbar',
|
||||
templateUrl: './toolbar.component.html'
|
||||
})
|
||||
export class ToolbarComponent {
|
||||
constructor(private fh: FormHelperService) {}
|
||||
|
||||
|
||||
settings() {
|
||||
this.fh.form(SettingsFormComponent, (config, close, banner) => {
|
||||
console.log(config);
|
||||
close();
|
||||
}, {disableAddAnother: true, matDialog: {width: 'min(100%, 600px)'}});
|
||||
}
|
||||
}
|
98
client/src/app/modules/crud-api/client.ts
Normal file
98
client/src/app/modules/crud-api/client.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import {includes} from '@transmute/common';
|
||||
import {distinctUntilChanged, map, Observable, share} from 'rxjs';
|
||||
import {ReactiveCache} from './reactiveCache';
|
||||
import {CrudApiEndpoint} from './endpoint';
|
||||
|
||||
export abstract class CrudApiClient<K, T> {
|
||||
public abstract api: CrudApiEndpoint<K, T>;
|
||||
|
||||
protected readonly cache = new ReactiveCache<K, T>();
|
||||
protected readonly groups = new ReactiveCache<Partial<T>, K[]>();
|
||||
protected readonly pending = new ReactiveCache<K | Partial<T>, Promise<T | T[]>>();
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.groups.clear();
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
list(filter?: Partial<T>, reload?: boolean): Promise<T[]> {
|
||||
const ck = filter ?? {};
|
||||
if(!reload) {
|
||||
const cache = this.groups.get(ck);
|
||||
if(cache) return Promise.resolve(cache.map(k => <T>this.cache.get(k)));
|
||||
const pending = this.pending.get(ck);
|
||||
if(pending) return <Promise<T[]>>pending;
|
||||
}
|
||||
return <Promise<T[]>>this.pending.set(ck, this.api.list(filter).then(rows => {
|
||||
this.groups.set(ck, rows.map(r => {
|
||||
const pk = (<any>r)[this.api.pk.toString()];
|
||||
this.cache.set(pk, r);
|
||||
return pk;
|
||||
}));
|
||||
return rows;
|
||||
}).finally(() => this.pending.delete(ck)));
|
||||
}
|
||||
|
||||
create(value: T): Promise<T> {
|
||||
return this.api.create(value).then(row => {
|
||||
const pk = (<any>row)[this.api.pk];
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
read(filter: K | Partial<T>, reload?: boolean): Promise<T> {
|
||||
const pk = typeof filter == 'object' ? (<any>filter)[this.api.pk] : filter;
|
||||
if(!reload) {
|
||||
const cache = this.cache.get(pk);
|
||||
if(cache) return Promise.resolve(cache);
|
||||
const pending = this.pending.get(pk);
|
||||
if(pending) return <Promise<T>>pending;
|
||||
}
|
||||
return <Promise<T>>this.pending.set(filter, this.api.read(filter).then(row => {
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
}).finally(() => this.pending.delete(pk)));
|
||||
}
|
||||
|
||||
update(value: Partial<T>): Promise<T> {
|
||||
return this.api.update(value).then(row => {
|
||||
const pk = (<any>row)[this.api.pk];
|
||||
this.cache.set(pk, row);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
if(includes(row, key, true))
|
||||
this.groups.set(key, [...cached, pk]);
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
delete(filter: K | Partial<T>): Promise<void> {
|
||||
const pk = typeof filter == 'object' ? (<any>filter)[this.api.pk] : filter;
|
||||
return this.api.delete(filter).then(() => {
|
||||
this.cache.delete(pk);
|
||||
this.groups.entries.forEach(([key, cached]) => {
|
||||
this.groups.set(key, cached.filter(k => k != pk));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listen(filter?: K | Partial<T>): Observable<T[]> {
|
||||
const key: Partial<T> = <any>(filter == null ? {} : typeof filter == 'object' ? filter : {[this.api.pk]: filter});
|
||||
this.list(key);
|
||||
return this.cache.events.pipe(
|
||||
map(cached => cached.filter(c => includes(c, key))),
|
||||
distinctUntilChanged(),
|
||||
share()
|
||||
);
|
||||
}
|
||||
}
|
52
client/src/app/modules/crud-api/endpoint.ts
Normal file
52
client/src/app/modules/crud-api/endpoint.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
|
||||
export abstract class CrudApiEndpoint<K, T> {
|
||||
protected abstract http: HttpClient;
|
||||
|
||||
getUrl!: (value?: K | Partial<T>) => string;
|
||||
|
||||
protected constructor(private readonly url: string, public readonly pk: keyof T) {
|
||||
const parts = url.split('/');
|
||||
if(url.indexOf('://') != -1) {
|
||||
const protocol = parts.splice(0, 2)[0];
|
||||
parts[0] = `${protocol}//${parts[0]}`;
|
||||
}
|
||||
|
||||
this.getUrl = (value?: K | Partial<T>) => {
|
||||
if(value == null) value = {};
|
||||
if(typeof value != 'object') value = <Partial<T>>{[this.pk]: value};
|
||||
let last: number;
|
||||
let newUrl: string = parts.map((p, i) => {
|
||||
if(p[0] != ':') return p;
|
||||
last = i;
|
||||
const optional = p.slice(-1) == '?';
|
||||
const key = p.slice(1, optional ? -1 : undefined);
|
||||
const val = (<any>value)?.[key];
|
||||
if(val == undefined && !optional)
|
||||
throw new Error(`'The request to "${url}" is missing the following key: ${key}\n\n${JSON.stringify(value)}`);
|
||||
return val;
|
||||
}).filter((p, i) => !!p || i > last).join('/');
|
||||
return newUrl;
|
||||
};
|
||||
}
|
||||
|
||||
list(filter?: Partial<T>, paginate?: { offset?: number; limit?: number }): Promise<T[]> {
|
||||
return <any>this.http.get<T[]>(this.getUrl(filter), {params: paginate}).toPromise();
|
||||
}
|
||||
|
||||
create(value: T): Promise<T> {
|
||||
return <any>this.http.post<T>(this.getUrl(value), value).toPromise();
|
||||
}
|
||||
|
||||
read(filter: K | Partial<T>): Promise<T> {
|
||||
return <any>this.http.get<T>(this.getUrl(filter)).toPromise();
|
||||
}
|
||||
|
||||
update(value: Partial<T>): Promise<T> {
|
||||
return <any>this.http.patch<T>(this.getUrl(value), value).toPromise();
|
||||
}
|
||||
|
||||
delete(filter: K | Partial<T>): Promise<void> {
|
||||
return this.http.delete<void>(this.getUrl(filter)).toPromise();
|
||||
}
|
||||
}
|
94
client/src/app/modules/crud-api/reactiveCache.ts
Normal file
94
client/src/app/modules/crud-api/reactiveCache.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {Subject} from 'rxjs';
|
||||
|
||||
/**
|
||||
* A mapped cached which accepts anything as a key. This is accomplished by serializing the values using
|
||||
* `JSON.stringify`. Objects go through the extra step of having their properties
|
||||
* sorted to ensure their order.
|
||||
* @template K - How the cache should be indexed
|
||||
* @template T - The type that will be cached
|
||||
*/
|
||||
export class ReactiveCache<K, T> {
|
||||
/** This is where everything is actually stored */
|
||||
private store = new Map<string, T>();
|
||||
|
||||
events = new Subject<T[]>();
|
||||
|
||||
/** Tuple array of keys & values */
|
||||
get entries(): [K, T][] { return [...this.store.entries()].map(([key, val]) => [!key ? key : JSON.parse(key), val]) }
|
||||
/** Cache keys in use */
|
||||
get keys(): K[] { return [...this.store.keys()].map(k => !k ? k : JSON.parse(k)); }
|
||||
/** Number of cached items */
|
||||
get size(): number { return this.store.size; }
|
||||
/** Returns all the stored rows */
|
||||
get values(): T[] { return [...this.store.values()]; }
|
||||
|
||||
/**
|
||||
* Serializes anything with order guaranteed (Array positions wont change & object properties are sorted)
|
||||
* @param value - Anything that needs to be serialized
|
||||
* @returns {string} - The serialized version of the data
|
||||
* @private
|
||||
*/
|
||||
private static serialize(value: any) {
|
||||
const _serialize: (value: any) => string = (value: any) => {
|
||||
if(Array.isArray(value)) return value.map(v => _serialize(v));
|
||||
if(value != null && typeof value == 'object') return Object.keys(value).sort()
|
||||
.reduce((acc, key) => ({...acc, [key]: _serialize(value[key])}), {});
|
||||
return value;
|
||||
};
|
||||
return JSON.stringify(_serialize(value));
|
||||
}
|
||||
|
||||
/** Clear everything from the cache */
|
||||
clear() {
|
||||
this.store.clear();
|
||||
this.events.next(this.values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached value
|
||||
* @param {K} key - Cache key
|
||||
*/
|
||||
delete(key: K) {
|
||||
this.store.delete(ReactiveCache.serialize(key));
|
||||
this.events.next(this.values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a value stored in the cache
|
||||
* @param {K} key - Cache key
|
||||
* @returns {T | undefined} - The cached value, or undefined if nothing is cached under the provided key
|
||||
*/
|
||||
get(key: K): T | undefined { return this.store.get(ReactiveCache.serialize(key)); }
|
||||
|
||||
/**
|
||||
* Check if the cache key has an attached value
|
||||
* @param {K} key - Cache key
|
||||
* @returns {boolean} - True if cached
|
||||
*/
|
||||
has(key: K): boolean { return this.store.has(ReactiveCache.serialize(key)); }
|
||||
|
||||
/**
|
||||
* Store a value in the cache with a cache key
|
||||
* @param {K} key - Index to store the value under
|
||||
* @param {T} value - What you will be storing
|
||||
*/
|
||||
set(key: K, value: T) {
|
||||
this.store.set(ReactiveCache.serialize(key), value);
|
||||
this.events.next(this.values);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// export class ApiCache<K, T> {
|
||||
// cache = new Map<K, T>();
|
||||
// pending = new Map<K, Promise<T>>();
|
||||
//
|
||||
// get(key: K, fetchFn: (key: K) => Promise<T>) {
|
||||
// if(this.cache[key]) return Promise.reject(this.cache[key]);
|
||||
// if(this.pending[key]) return this.pending[key];
|
||||
// return fetchFn(key).then(res => {
|
||||
// this.cache[key] = res;
|
||||
// return res;
|
||||
// }).finally(() => this.pending[key] = undefined);
|
||||
// }
|
||||
// }
|
@ -0,0 +1,12 @@
|
||||
<h1 *ngIf="options.title" mat-dialog-title class="mb-3">{{options.title}}</h1>
|
||||
<div mat-dialog-content class="fh-dialog-form">
|
||||
{{options.message}}
|
||||
</div>
|
||||
<div mat-dialog-actions style="justify-content: end">
|
||||
<button mat-button [mat-dialog-close]="false">
|
||||
{{options.cancelLabel}}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [mat-dialog-close]="true">
|
||||
{{options.confirmLabel}}
|
||||
</button>
|
||||
</div>
|
@ -0,0 +1,22 @@
|
||||
import {Component, Inject} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA} from '@angular/material/dialog';
|
||||
|
||||
export type ConfirmDialogOptions = {
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
title?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'fh-confirm-dialog',
|
||||
templateUrl: './confirm-dialog.component.html'
|
||||
})
|
||||
export class ConfirmDialogComponent {
|
||||
constructor(@Inject(MAT_DIALOG_DATA) public readonly options: ConfirmDialogOptions) {
|
||||
Object.assign(this.options, {
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Ok',
|
||||
}, this.options);
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import {ControlValueAccessor, FormGroup} from '@angular/forms';
|
||||
import {deepCopy, isEqual} from '@transmute/common';
|
||||
|
||||
@Component({template: ''})
|
||||
export abstract class FormBoilerplateComponent<T extends Object> implements ControlValueAccessor {
|
||||
private original?: T;
|
||||
value?: T;
|
||||
|
||||
private _changes: any = () => {};
|
||||
registerOnChange(fn: any): void { this._changes = fn; }
|
||||
|
||||
private _touched: any = () => {};
|
||||
registerOnTouched(fn: any): void { this._touched = fn; }
|
||||
|
||||
@Input() disabled?: boolean;
|
||||
@Input() mode: 'new' | 'edit' = 'new';
|
||||
|
||||
@Output() submit$ = new EventEmitter<void>();
|
||||
@Output() reset$ = new EventEmitter<void>();
|
||||
|
||||
protected constructor(public readonly form: FormGroup) {
|
||||
this.form.valueChanges.subscribe(value => {
|
||||
this.value = {...this.value, ...value}
|
||||
this._changes(this.value);
|
||||
})
|
||||
}
|
||||
|
||||
protected abstract resetHook(preventDefault: () => void): void;
|
||||
protected abstract writeHook(value: T, preventDefault: () => void): void;
|
||||
protected abstract validateHook(value: T, preventDefault: () => void): void | boolean;
|
||||
|
||||
@HostListener('document:keyup.enter')
|
||||
private submit() { this.submit$.emit(); }
|
||||
|
||||
getError(control: string): {key: string, args: any} | null {
|
||||
const c = this.form.get(control);
|
||||
if(!!c?.errors) {
|
||||
const key = Object.keys(c.errors)[0];
|
||||
return {key, args: c.errors[key]};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getValue(control: string): any {
|
||||
return this.form.get(control) || (<any>this.value)[control];
|
||||
}
|
||||
|
||||
reset() {
|
||||
let preventDefault = false;
|
||||
this.resetHook(() => preventDefault = true);
|
||||
if(preventDefault) return;
|
||||
|
||||
this.form.reset();
|
||||
this.value = this.original ?? undefined;
|
||||
if(this.original) this.form.setValue(this.original);
|
||||
this.reset$.emit();
|
||||
}
|
||||
|
||||
setDisabledState?(disabled: boolean): void {
|
||||
this.disabled = disabled;
|
||||
this.disabled ? this.form.disable() : this.form.enable();
|
||||
}
|
||||
|
||||
writeValue(value: Partial<T>): void {
|
||||
if(this.disabled || isEqual(this.value, {...(this.value ?? {}), ...value})) return;
|
||||
if(!this.original) {
|
||||
this.original = <T>(deepCopy(value) || {});
|
||||
Object.keys(this.form.controls).forEach((key: string) => {
|
||||
if(this.original && (<any>this.original)[key] == undefined)
|
||||
(<any>this.original)[key] = null;
|
||||
});
|
||||
}
|
||||
this.value = {...(this.value ?? this.original), ...value};
|
||||
let preventDefault = false;
|
||||
this.writeHook(this.value, () => preventDefault = true);
|
||||
if(preventDefault) return;
|
||||
this.form.patchValue(this.value);
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
<h1 *ngIf="options.title" mat-dialog-title class="mb-3">{{options.title}}</h1>
|
||||
<div *ngIf="banner.text" class="alert" [classList]="banner.cssClass" role="alert">
|
||||
{{banner.text}}
|
||||
</div>
|
||||
<div mat-dialog-content class="fh-dialog-form">
|
||||
<ng-template addHost></ng-template>
|
||||
</div>
|
||||
<div mat-dialog-actions style="justify-content: end">
|
||||
<mat-checkbox *ngIf="!options?.disableAddAnother" class="mb-0 me-3" [(ngModel)]="anotherOne" [ngModelOptions]="{standalone: true}">
|
||||
{{options.addAnotherLabel}}
|
||||
</mat-checkbox>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{options.cancelLabel}}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" type="submit" (click)="save()" [disabled]="loading">
|
||||
{{options.saveLabel}}
|
||||
</button>
|
||||
</div>
|
@ -0,0 +1,115 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Directive, ElementRef,
|
||||
Inject, OnDestroy, OnInit, ViewChild, ViewContainerRef,
|
||||
} from '@angular/core';
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {FormBoilerplateComponent} from '../form-boilerplate/form-boilerplate.component';
|
||||
|
||||
export type FormDialogBanner = {
|
||||
text?: string,
|
||||
cssClass?: string
|
||||
};
|
||||
|
||||
export type FormDialogOptions<T extends object> = {
|
||||
addAnotherLabel?: string;
|
||||
banner?: FormDialogBanner;
|
||||
cancelLabel?: string;
|
||||
disableAddAnother?: boolean;
|
||||
form: FormBoilerplateComponent<T>;
|
||||
formArgs?: any;
|
||||
saveFn: FormDialogSaveFn<T>;
|
||||
saveLabel?: string;
|
||||
title?: string;
|
||||
value?: T | (() => T);
|
||||
};
|
||||
|
||||
export type FormDialogSaveFn<T> = (value: T, closeFn: () => void, bannerFn: (text: string, cssClass?: string) => void) => void | Promise<void>;
|
||||
|
||||
@Directive({
|
||||
selector: '[addHost]',
|
||||
})
|
||||
export class AddHostDirective {
|
||||
constructor(public viewContainerRef: ViewContainerRef) { }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'fh-form-dialog',
|
||||
templateUrl: './form-dialog.component.html'
|
||||
})
|
||||
export class FormDialogComponent<T extends Object, F extends FormBoilerplateComponent<T>> implements OnInit, OnDestroy {
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
anotherOne: boolean = false;
|
||||
banner: FormDialogBanner = {text: '', cssClass: ''}
|
||||
form!: FormBoilerplateComponent<T>;
|
||||
loading: boolean = false;
|
||||
|
||||
@ViewChild(AddHostDirective, {static: true}) addHost!: AddHostDirective;
|
||||
@ViewChild(AddHostDirective, { read: ElementRef }) addHostEl!:ElementRef;
|
||||
|
||||
constructor(private changeDetector: ChangeDetectorRef,
|
||||
private dialog: MatDialogRef<FormDialogComponent<T, F>>,
|
||||
@Inject(MAT_DIALOG_DATA) public readonly options: FormDialogOptions<T>
|
||||
) {
|
||||
Object.assign(this.options, {
|
||||
addAnotherLabel: 'Add Another',
|
||||
cancelLabel: 'Cancel',
|
||||
saveLabel: 'Save',
|
||||
}, this.options);
|
||||
this.setBanner(this.options.banner?.text, this.options.banner?.cssClass);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.addHost.viewContainerRef.clear();
|
||||
this.form = <any>this.addHost.viewContainerRef.createComponent(<any>this.options.form).instance;
|
||||
if(this.options.formArgs) Object.assign(this.form, this.options.formArgs);
|
||||
if(this.options.value) {
|
||||
this.form.mode = 'edit';
|
||||
this.options.disableAddAnother = true;
|
||||
this.form.writeValue(
|
||||
typeof this.options.value == 'function'
|
||||
? this.options.value()
|
||||
: this.options.value
|
||||
);
|
||||
}
|
||||
this.subs.push(this.form.submit$.subscribe(() => this.save()));
|
||||
}
|
||||
|
||||
ngOnDestroy() { this.subs = this.subs.filter(s => s.unsubscribe()); }
|
||||
|
||||
save(): void {
|
||||
// TODO: connect form properties to dialog
|
||||
if(!this.form.value) return;
|
||||
this.loading = true;
|
||||
this.clearBanner();
|
||||
this.changeDetector.detectChanges();
|
||||
const value = this.form.value;
|
||||
const resp = this.options.saveFn(<any>value, () => {
|
||||
if(!this.anotherOne) {
|
||||
this.dialog.close(value);
|
||||
} else {
|
||||
this.clearBanner();
|
||||
this.form.reset();
|
||||
const control = <HTMLElement>document
|
||||
.querySelector('.fh-dialog-form [formcontrolname]:not(:disabled)');
|
||||
setTimeout(() => control.focus());
|
||||
}
|
||||
}, (text, cssClass) => this.setBanner(text, cssClass))
|
||||
if(resp instanceof Promise) {
|
||||
resp.finally(() => {
|
||||
this.loading = false;
|
||||
this.changeDetector.detectChanges();
|
||||
});
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
clearBanner(): void { this.setBanner(); }
|
||||
setBanner(text?: string, cssClass: string = 'alert alert-danger') {
|
||||
this.banner = {text: text ?? '', cssClass};
|
||||
}
|
||||
}
|
32
client/src/app/modules/form-helper/form-helper.module.ts
Normal file
32
client/src/app/modules/form-helper/form-helper.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {ConfirmDialogComponent} from './components/confirm-dialog/confirm-dialog.component';
|
||||
import {AddHostDirective, FormDialogComponent} from './components/form-dialog/form-dialog.component';
|
||||
import {FormHelperService} from './services/form-helper.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatIconModule
|
||||
],
|
||||
declarations: [
|
||||
AddHostDirective,
|
||||
ConfirmDialogComponent,
|
||||
FormDialogComponent
|
||||
],
|
||||
providers: [FormHelperService],
|
||||
exports: [
|
||||
ConfirmDialogComponent,
|
||||
FormDialogComponent
|
||||
]
|
||||
})
|
||||
export class FormHelperModule { }
|
2
client/src/app/modules/form-helper/index.ts
Normal file
2
client/src/app/modules/form-helper/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './form-helper.module';
|
||||
export * from './components/form-boilerplate/form-boilerplate.component';
|
@ -0,0 +1,46 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
|
||||
import {ConfirmDialogComponent, ConfirmDialogOptions} from '../components/confirm-dialog/confirm-dialog.component';
|
||||
import {
|
||||
FormDialogComponent,
|
||||
FormDialogOptions,
|
||||
FormDialogSaveFn
|
||||
} from '../components/form-dialog/form-dialog.component';
|
||||
|
||||
export type FormHelperConfirmDialogOptions =
|
||||
Omit<ConfirmDialogOptions, 'message' | 'title'> & {matDialog?: MatDialogConfig}
|
||||
|
||||
export type FormHelperOpenDialogOptions<T extends object> =
|
||||
Omit<FormDialogOptions<T>, 'form' | 'saveFn'> & {matDialog?: MatDialogConfig}
|
||||
|
||||
@Injectable()
|
||||
export class FormHelperService {
|
||||
constructor(private dialog: MatDialog) { }
|
||||
|
||||
confirm(title: string, message: string, options: FormHelperConfirmDialogOptions = {}): Promise<boolean> {
|
||||
return this.dialog.open(ConfirmDialogComponent, {
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
...options?.matDialog,
|
||||
data: {
|
||||
...options,
|
||||
title,
|
||||
message,
|
||||
matDialog: undefined
|
||||
}
|
||||
}).afterClosed().toPromise();
|
||||
}
|
||||
|
||||
form<T extends object>(form: any, saveFn: FormDialogSaveFn<T>, options: FormHelperOpenDialogOptions<T> = {}): Promise<T | null> {
|
||||
return this.dialog.open(FormDialogComponent, {
|
||||
disableClose: true,
|
||||
...options?.matDialog,
|
||||
data: {
|
||||
...options,
|
||||
form,
|
||||
matDialog: undefined,
|
||||
saveFn,
|
||||
}
|
||||
}).afterClosed().toPromise();
|
||||
}
|
||||
}
|
56
client/src/app/modules/material.module.js
Normal file
56
client/src/app/modules/material.module.js
Normal file
@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
exports.__esModule = true;
|
||||
exports.MaterialModule = void 0;
|
||||
var core_1 = require("@angular/core");
|
||||
var button_1 = require("@angular/material/button");
|
||||
var card_1 = require("@angular/material/card");
|
||||
var checkbox_1 = require("@angular/material/checkbox");
|
||||
var chips_1 = require("@angular/material/chips");
|
||||
var dialog_1 = require("@angular/material/dialog");
|
||||
var divider_1 = require("@angular/material/divider");
|
||||
var form_field_1 = require("@angular/material/form-field");
|
||||
var icon_1 = require("@angular/material/icon");
|
||||
var input_1 = require("@angular/material/input");
|
||||
var list_1 = require("@angular/material/list");
|
||||
var paginator_1 = require("@angular/material/paginator");
|
||||
var progress_bar_1 = require("@angular/material/progress-bar");
|
||||
var radio_1 = require("@angular/material/radio");
|
||||
var select_1 = require("@angular/material/select");
|
||||
var tabs_1 = require("@angular/material/tabs");
|
||||
var toolbar_1 = require("@angular/material/toolbar");
|
||||
var MATERIAL_MODULES = [
|
||||
toolbar_1.MatToolbarModule,
|
||||
button_1.MatButtonModule,
|
||||
card_1.MatCardModule,
|
||||
checkbox_1.MatCheckboxModule,
|
||||
chips_1.MatChipsModule,
|
||||
dialog_1.MatDialogModule,
|
||||
divider_1.MatDividerModule,
|
||||
form_field_1.MatFormFieldModule,
|
||||
icon_1.MatIconModule,
|
||||
input_1.MatInputModule,
|
||||
list_1.MatListModule,
|
||||
paginator_1.MatPaginatorModule,
|
||||
progress_bar_1.MatProgressBarModule,
|
||||
radio_1.MatRadioModule,
|
||||
select_1.MatSelectModule,
|
||||
tabs_1.MatTabsModule,
|
||||
];
|
||||
var MaterialModule = /** @class */ (function () {
|
||||
function MaterialModule() {
|
||||
}
|
||||
MaterialModule = __decorate([
|
||||
(0, core_1.NgModule)({
|
||||
imports: MATERIAL_MODULES,
|
||||
exports: MATERIAL_MODULES
|
||||
})
|
||||
], MaterialModule);
|
||||
return MaterialModule;
|
||||
}());
|
||||
exports.MaterialModule = MaterialModule;
|
48
client/src/app/modules/material.module.ts
Normal file
48
client/src/app/modules/material.module.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
import {MatCheckboxModule} from '@angular/material/checkbox';
|
||||
import {MatChipsModule} from '@angular/material/chips';
|
||||
import {MatDialogModule} from '@angular/material/dialog';
|
||||
import {MatDividerModule} from '@angular/material/divider';
|
||||
import {MatExpansionModule} from '@angular/material/expansion';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatListModule} from '@angular/material/list';
|
||||
import {MatMenuModule} from '@angular/material/menu';
|
||||
import {MatPaginatorModule} from '@angular/material/paginator';
|
||||
import {MatProgressBarModule} from '@angular/material/progress-bar';
|
||||
import {MatRadioModule} from '@angular/material/radio';
|
||||
import {MatSelectModule} from '@angular/material/select';
|
||||
import {MatTabsModule} from '@angular/material/tabs';
|
||||
import {MatToolbarModule} from '@angular/material/toolbar';
|
||||
import {MatTooltipModule} from '@angular/material/tooltip';
|
||||
|
||||
const MATERIAL_MODULES = [
|
||||
MatToolbarModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatDividerModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatRadioModule,
|
||||
MatSelectModule,
|
||||
MatTabsModule,
|
||||
MatTooltipModule,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: MATERIAL_MODULES,
|
||||
exports: MATERIAL_MODULES,
|
||||
})
|
||||
export class MaterialModule {}
|
11
client/src/app/pipes/size.pipe.ts
Normal file
11
client/src/app/pipes/size.pipe.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({name: 'size'})
|
||||
export class SizePipe implements PipeTransform {
|
||||
transform(size: number = 0) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let i = 0;
|
||||
while(size / (1024 ** i) > 1024) i++;
|
||||
return `${Number((size / (1024 ** i)).toFixed(1))} ${units[i]}`;
|
||||
}
|
||||
}
|
30
client/src/app/services/library.service.ts
Normal file
30
client/src/app/services/library.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Library, Metrics, Video} from '@transmute/common';
|
||||
import {environment} from '../../environments/environment';
|
||||
import {CrudApiClient} from '../modules/crud-api/client';
|
||||
import {CrudApiEndpoint} from '../modules/crud-api/endpoint';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class LibraryEndpoint extends CrudApiEndpoint<number, Library> {
|
||||
constructor(protected http: HttpClient) {
|
||||
super(`${environment.apiUrl}/api/library/:id?`, 'id');
|
||||
}
|
||||
|
||||
metrics(library?: number): Promise<Metrics> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/metrics`).toPromise();
|
||||
}
|
||||
|
||||
scan(library?: number): Promise<{length: number}> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/scan`).toPromise();
|
||||
}
|
||||
|
||||
videos(library?: number): Promise<Video[]> {
|
||||
return this.http.get<any>(`${this.getUrl({id: library})}/videos`).toPromise();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class LibraryClient extends CrudApiClient<number, Library> {
|
||||
constructor(public api: LibraryEndpoint) { super(); }
|
||||
}
|
149
client/src/app/views/app.component.html
Normal file
149
client/src/app/views/app.component.html
Normal file
@ -0,0 +1,149 @@
|
||||
<!-- Toolbar -->
|
||||
<tm-toolbar />
|
||||
<!-- Viewport -->
|
||||
<div class="max-height d-flex flex-row">
|
||||
<!-- Main panel -->
|
||||
<div class="d-flex flex-column p-4" style="flex-grow: 2">
|
||||
<!-- Library Selector -->
|
||||
<tm-library-selector [(selected)]="library" (selectedChange)="librarySelected($event)" />
|
||||
<mat-accordion class="d-flex flex-column flex-grow-1" multi>
|
||||
<!-- Charts -->
|
||||
<mat-expansion-panel [disabled]="!metrics">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<span>Metrics</span>
|
||||
<span class="ms-3 text-muted" *ngIf="metrics">Files: {{metrics.videos}}</span>
|
||||
<span class="ms-3 text-muted" *ngIf="metrics">Size: {{ metrics.size | size }}</span>
|
||||
</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div *ngIf="metrics">
|
||||
<div class="d-flex justify-content-between">
|
||||
<tm-pichart title="Resolution" [data]="metrics.resolution"></tm-pichart>
|
||||
<tm-pichart title="Container" [data]="metrics.container"></tm-pichart>
|
||||
<tm-pichart title="Video Codec" [data]="metrics.videoCodec"></tm-pichart>
|
||||
<tm-pichart title="Audio Codec" [data]="metrics.audioCodec"></tm-pichart>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<tm-pichart title="Health" [data]="metrics.health"></tm-pichart>
|
||||
<tm-pichart title="Audio Languages" [data]="metrics.audioLang"></tm-pichart>
|
||||
<tm-pichart title="Subtitle Languages" [data]="metrics.subLang"></tm-pichart>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
<!-- Files -->
|
||||
<mat-expansion-panel expanded class="expanded-fill d-flex flex-column">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>Files</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<!-- Table -->
|
||||
<div class="flex-grow-0">
|
||||
<table class="table">
|
||||
<colgroup>
|
||||
<col>
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
<col style="width: 125px">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Resolution</th>
|
||||
<th>Container</th>
|
||||
<th>Video Codec</th>
|
||||
<th>Audio Codec</th>
|
||||
<th># Audio</th>
|
||||
<th># Subtitle</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let v of videos">
|
||||
<td>
|
||||
<span class="me-3">{{v.name}}</span>
|
||||
<mat-chip *ngIf="v.healthy == null">Unknown</mat-chip>
|
||||
<mat-chip *ngIf="v.healthy" style="background: #0f0">Healthy</mat-chip>
|
||||
<mat-chip *ngIf="v.healthy == false" style="background: #f00">Unhealthy</mat-chip>
|
||||
</td>
|
||||
<td>{{v.resolution}}</td>
|
||||
<td>{{v.container}}</td>
|
||||
<td>{{v.videoCodec}}</td>
|
||||
<td>{{v.audioCodec}}</td>
|
||||
<td class="text-decoration-underline" [matTooltip]="list(v.audioTracks)">
|
||||
{{v.audioTracks?.length}}
|
||||
</td>
|
||||
<td class="text-decoration-underline" [matTooltip]="list(v.subtitleTracks)">
|
||||
{{v.subtitleTracks?.length}}
|
||||
</td>
|
||||
<td>{{v.size | size}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="flex-grow-0">
|
||||
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [length]="metrics?.videos ?? 0"></mat-paginator>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
<!-- Side Panel -->
|
||||
<div class="py-4 pe-4 h-100" style="flex-grow: 1">
|
||||
<mat-card class="h-100">
|
||||
<mat-card-content class="p-0">
|
||||
<mat-tab-group>
|
||||
<!-- Queue -->
|
||||
<mat-tab label="Queue">
|
||||
<mat-divider></mat-divider>
|
||||
</mat-tab>
|
||||
<!-- Logs -->
|
||||
<mat-tab label="Logs">
|
||||
<mat-divider></mat-divider>
|
||||
</mat-tab>
|
||||
<!-- Workers -->
|
||||
<mat-tab label="Workers">
|
||||
<mat-divider></mat-divider>
|
||||
<div class="agent-list">
|
||||
<div *ngFor="let n of nodes" style="height: auto" class="mt-2 agent">
|
||||
<div class="d-flex px-3 align-items-center">
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job == null">storage
|
||||
</mat-icon>
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job?.type == 'healthcheck'">troubleshoot
|
||||
</mat-icon>
|
||||
<mat-icon style="height: 32px; width: 32px; font-size: 32px"
|
||||
*ngIf="n.job?.type == 'transcode'">rotate_right
|
||||
</mat-icon>
|
||||
<div class="ms-3 d-flex flex-column flex-grow-1">
|
||||
<span>{{n.name}}</span>
|
||||
<span class="text-muted">
|
||||
State: {{n.job ? n.job.type.toUpperCase() : 'IDLE'}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-100">
|
||||
<button mat-icon-button class="mb-2 agent-settings">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="n.job?.file">
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
<div class="d-flex justify-content-between text-muted mt-2 px-3">
|
||||
<div>{{n.job?.file?.name}}</div>
|
||||
<div>60%</div>
|
||||
</div>
|
||||
</div>
|
||||
<mat-divider class="mt-2"></mat-divider>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
0
client/src/app/views/app.component.scss
Normal file
0
client/src/app/views/app.component.scss
Normal file
44
client/src/app/views/app.component.ts
Normal file
44
client/src/app/views/app.component.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {Job, Library, Metrics, Video} from '@transmute/common';
|
||||
import {LibraryClient} from '../services/library.service';
|
||||
|
||||
export type Node = {
|
||||
name: string;
|
||||
job: Job | null
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
library?: Library;
|
||||
metrics?: Metrics;
|
||||
nodes: Node[] = [
|
||||
{name: 'manager-1', job: null},
|
||||
{name: 'Node-2', job: null}
|
||||
];
|
||||
videos: Video[] = [];
|
||||
|
||||
constructor(private dialog: MatDialog,
|
||||
private libraryApi: LibraryClient,
|
||||
private changeRef: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.librarySelected();
|
||||
}
|
||||
|
||||
librarySelected(library?: Library) {
|
||||
Promise.all([
|
||||
this.libraryApi.api.videos(library?.id).then(videos => this.videos = videos),
|
||||
this.libraryApi.api.metrics(library?.id).then((m: any) => this.metrics = m)
|
||||
]).then(() => this.changeRef.detectChanges());
|
||||
}
|
||||
|
||||
list(arr?: string[]): string {
|
||||
return !!arr ? arr.join(', ') : '';
|
||||
}
|
||||
}
|
BIN
client/src/assets/img/favicon.png
Normal file
BIN
client/src/assets/img/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
4
client/src/environments/environment.prod.ts
Normal file
4
client/src/environments/environment.prod.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
apiUrl: '{{API_URL}}',
|
||||
production: true,
|
||||
};
|
4
client/src/environments/environment.ts
Normal file
4
client/src/environments/environment.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
apiUrl: 'http://localhost:5000',
|
||||
production: true,
|
||||
}
|
18
client/src/index.html
Normal file
18
client/src/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Transmute</title>
|
||||
<base href="/">
|
||||
|
||||
<link rel="icon" type="image/png" href="/assets/img/favicon.png">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="mat-typography">
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
9
client/src/main.ts
Normal file
9
client/src/main.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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));
|
69
client/src/styles.scss
Normal file
69
client/src/styles.scss
Normal file
@ -0,0 +1,69 @@
|
||||
@use 'bootstrap/dist/css/bootstrap.min.css';
|
||||
// Custom Theming for Angular Material
|
||||
// For more information: https://material.angular.io/guide/theming
|
||||
@use '@angular/material' as mat;
|
||||
// Plus imports for other components in your app.
|
||||
|
||||
// Include the common styles for Angular Material. We include this here so that you only
|
||||
// have to load a single css file for Angular Material in your app.
|
||||
// Be sure that you only ever include this mixin once!
|
||||
@include mat.core();
|
||||
|
||||
// Define the palettes for your theme using the Material Design palettes available in palette.scss
|
||||
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
|
||||
// hue. Available color palettes: https://material.io/design/color/
|
||||
$transmute-client-primary: mat.define-palette(mat.$indigo-palette);
|
||||
$transmute-client-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
|
||||
|
||||
// The warn palette is optional (defaults to red).
|
||||
$transmute-client-warn: mat.define-palette(mat.$red-palette);
|
||||
|
||||
// Create the theme object. A theme consists of configurations for individual
|
||||
// theming systems such as "color" or "typography".
|
||||
$transmute-client-theme: mat.define-light-theme((
|
||||
color: (
|
||||
primary: $transmute-client-primary,
|
||||
accent: $transmute-client-accent,
|
||||
warn: $transmute-client-warn,
|
||||
)
|
||||
));
|
||||
|
||||
// Include theme styles for core and each component used in your app.
|
||||
// Alternatively, you can import and @include the theme mixins for each component
|
||||
// that you are using.
|
||||
@include mat.all-component-themes($transmute-client-theme);
|
||||
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
||||
|
||||
.max-height { height: calc(100vh - 50px); }
|
||||
|
||||
.agent-list {
|
||||
.agent {
|
||||
&:hover {
|
||||
.agent-settings {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-settings {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
input.hide-incrementor::-webkit-outer-spin-button,
|
||||
input.hide-incrementor::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-expanded.expanded-fill {
|
||||
flex-grow: 1;
|
||||
}
|
Reference in New Issue
Block a user