This commit is contained in:
2023-08-14 14:36:45 -04:00
commit b5966f98b2
94 changed files with 21124 additions and 0 deletions

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

View File

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

View File

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

View File

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

View File

@ -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);
}
}

View File

@ -0,0 +1,6 @@
<h2>{{title}}</h2>
<canvas baseChart
[data]="dataset"
[options]="options"
type="doughnut">
</canvas>

View 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}]};
}
}
}

View File

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

View File

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

View 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>

View 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)'}});
}
}

View 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()
);
}
}

View 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();
}
}

View 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);
// }
// }

View File

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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

View File

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

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

View File

@ -0,0 +1,2 @@
export * from './form-helper.module';
export * from './components/form-boilerplate/form-boilerplate.component';

View File

@ -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();
}
}

View 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;

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

View 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]}`;
}
}

View 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(); }
}

View 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>

View File

View 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(', ') : '';
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,4 @@
export const environment = {
apiUrl: '{{API_URL}}',
production: true,
};

View File

@ -0,0 +1,4 @@
export const environment = {
apiUrl: 'http://localhost:5000',
production: true,
}

18
client/src/index.html Normal file
View 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
View 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
View 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;
}