init
This commit is contained in:
		
							
								
								
									
										35
									
								
								client/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								client/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
 | 
			
		||||
 | 
			
		||||
# Compiled output
 | 
			
		||||
.angular
 | 
			
		||||
bazel-out
 | 
			
		||||
coverage
 | 
			
		||||
dist
 | 
			
		||||
tmp
 | 
			
		||||
out-tsc
 | 
			
		||||
.sass-cache/
 | 
			
		||||
 | 
			
		||||
# Node
 | 
			
		||||
node_modules
 | 
			
		||||
npm-debug.log
 | 
			
		||||
yarn-error.log
 | 
			
		||||
 | 
			
		||||
# IDEs and editors
 | 
			
		||||
.idea
 | 
			
		||||
.project
 | 
			
		||||
.classpath
 | 
			
		||||
.c9
 | 
			
		||||
*.launch
 | 
			
		||||
.settings
 | 
			
		||||
*.sublime-workspace
 | 
			
		||||
.vscode
 | 
			
		||||
 | 
			
		||||
# Miscellaneous
 | 
			
		||||
connect.lock
 | 
			
		||||
libpeerconnection.log
 | 
			
		||||
testem.log
 | 
			
		||||
typings
 | 
			
		||||
 | 
			
		||||
# System files
 | 
			
		||||
.DS_Store
 | 
			
		||||
Thumbs.db
 | 
			
		||||
							
								
								
									
										86
									
								
								client/.gitlab/.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								client/.gitlab/.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
image: node:18
 | 
			
		||||
 | 
			
		||||
npm:
 | 
			
		||||
  stage: build
 | 
			
		||||
  cache:
 | 
			
		||||
    - key:
 | 
			
		||||
        files:
 | 
			
		||||
          - package-lock.json
 | 
			
		||||
      paths:
 | 
			
		||||
        - node_modules
 | 
			
		||||
      policy: pull-push
 | 
			
		||||
    - key: $CI_PIPELINE_ID
 | 
			
		||||
      paths:
 | 
			
		||||
        - dist
 | 
			
		||||
      policy: push
 | 
			
		||||
  script:
 | 
			
		||||
    - npm install
 | 
			
		||||
    - npm run build
 | 
			
		||||
  artifacts:
 | 
			
		||||
    paths:
 | 
			
		||||
      - dist
 | 
			
		||||
    expire_in: 1 week
 | 
			
		||||
  rules:
 | 
			
		||||
    - if: $CI_COMMIT_BRANCH
 | 
			
		||||
 | 
			
		||||
audit:
 | 
			
		||||
  stage: test
 | 
			
		||||
  cache:
 | 
			
		||||
    - key:
 | 
			
		||||
        files:
 | 
			
		||||
          - package-lock.json
 | 
			
		||||
      paths:
 | 
			
		||||
        - node_modules
 | 
			
		||||
      policy: pull
 | 
			
		||||
  script:
 | 
			
		||||
    - echo "vulnerabilities_high $(npm audit | grep -oE '[0-9]+ high' | grep -oE '[0-9]+' || echo 0)" > metrics.txt
 | 
			
		||||
    - echo "vulnerabilities_medium $(npm audit | grep -oE '[0-9]+ moderate' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
 | 
			
		||||
    - echo "vulnerabilities_low $(npm audit | grep -oE '[0-9]+ low' | grep -oE '[0-9]+' || echo 0)" >> metrics.txt
 | 
			
		||||
  artifacts:
 | 
			
		||||
    reports:
 | 
			
		||||
      metrics: metrics.txt
 | 
			
		||||
  rules:
 | 
			
		||||
    - if: $CI_COMMIT_BRANCH
 | 
			
		||||
 | 
			
		||||
registry:
 | 
			
		||||
  stage: deploy
 | 
			
		||||
  cache:
 | 
			
		||||
    - key:
 | 
			
		||||
        files:
 | 
			
		||||
          - package.json
 | 
			
		||||
      paths:
 | 
			
		||||
        - node_modules
 | 
			
		||||
      policy: pull
 | 
			
		||||
    - key: $CI_PIPELINE_ID
 | 
			
		||||
      paths:
 | 
			
		||||
        - dist
 | 
			
		||||
      policy: pull
 | 
			
		||||
  before_script:
 | 
			
		||||
    - VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
 | 
			
		||||
    - if [ "$CI_COMMIT_BRANCH" != "$CI_DEFAULT_BRANCH" ] && [ "$VERSION" != *"-$CI_COMMIT_BRANCH" ]; then VERSION="$VERSION-$(echo "$CI_COMMIT_BRANCH" | sed -E "s/[_/]/-/g")"; npm version --no-git-tag-version $VERSION; fi
 | 
			
		||||
  script:
 | 
			
		||||
    - PACKAGES=$(curl -s -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages)
 | 
			
		||||
    - ID=$(node -pe "JSON.parse(process.argv[1]).find(p => p['version'] == process.argv[2])?.id || ''" $PACKAGES $VERSION)
 | 
			
		||||
    - if [ -n "$ID" ]; then curl -s -X DELETE -H "PRIVATE-TOKEN:$DEPLOY_TOKEN" https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/$ID; fi
 | 
			
		||||
    - printf "@transmute:registry=https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/\n//$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/packages/npm/:_authToken=$DEPLOY_TOKEN" > .npmrc
 | 
			
		||||
    - npm publish
 | 
			
		||||
  rules:
 | 
			
		||||
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
 | 
			
		||||
    - if: $CI_COMMIT_BRANCH
 | 
			
		||||
      when: manual
 | 
			
		||||
      allow_failure: true
 | 
			
		||||
 | 
			
		||||
tag:
 | 
			
		||||
  stage: deploy
 | 
			
		||||
  image:
 | 
			
		||||
    name: alpine/git
 | 
			
		||||
    entrypoint: [""]
 | 
			
		||||
  cache: []
 | 
			
		||||
  before_script:
 | 
			
		||||
    - git remote set-url origin "https://ReleaseBot:$DEPLOY_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git"
 | 
			
		||||
  script:
 | 
			
		||||
    - VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')
 | 
			
		||||
    - git tag -f $VERSION $CI_COMMIT_SHA
 | 
			
		||||
    - git push -f origin $VERSION
 | 
			
		||||
  rules:
 | 
			
		||||
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
 | 
			
		||||
							
								
								
									
										2
									
								
								client/.npmrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								client/.npmrc
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
@transmute:registry=https://gitlab.zakscode.com/api/v4/projects/85/packages/npm/
 | 
			
		||||
//gitlab.zakscode.com/api/v4/projects/85/packages/npm/:_authToken=tvNAnPtzjy59xFrHBJ2J
 | 
			
		||||
							
								
								
									
										32
									
								
								client/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								client/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
# Transmute Client
 | 
			
		||||
 | 
			
		||||
This is Web UI part of the Transmute stack which talks directly to the Transmute Server/API.
 | 
			
		||||
 | 
			
		||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.2.2.
 | 
			
		||||
 | 
			
		||||
## Table of Contents
 | 
			
		||||
 | 
			
		||||
[[_TOC_]]
 | 
			
		||||
 | 
			
		||||
## Prerequisites
 | 
			
		||||
 | 
			
		||||
- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
 | 
			
		||||
- [NodeJS 18](https://nodejs.org/en/)
 | 
			
		||||
- _[Docker](https://docs.docker.com/install/) (Optional)_
 | 
			
		||||
 | 
			
		||||
## Setup
 | 
			
		||||
The project can be run using NPM.
 | 
			
		||||
 | 
			
		||||
1. Install the dependencies: `npm install`
 | 
			
		||||
2. Start the Angular server: `npm run start`
 | 
			
		||||
 | 
			
		||||
The client should now be accessible on [http://localhost:4200](http://localhost:4200)
 | 
			
		||||
 | 
			
		||||
## Cheatsheet
 | 
			
		||||
```bash
 | 
			
		||||
# Start Angular server
 | 
			
		||||
npm run start
 | 
			
		||||
 | 
			
		||||
# Build production
 | 
			
		||||
npm run build:prod
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										75
									
								
								client/angular.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								client/angular.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
{
 | 
			
		||||
	"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
 | 
			
		||||
	"version": 1,
 | 
			
		||||
	"newProjectRoot": "projects",
 | 
			
		||||
	"projects": {
 | 
			
		||||
		"transmute-client": {
 | 
			
		||||
			"projectType": "application",
 | 
			
		||||
			"schematics": {
 | 
			
		||||
				"@schematics/angular:component": {
 | 
			
		||||
					"style": "scss"
 | 
			
		||||
				}
 | 
			
		||||
			},
 | 
			
		||||
			"root": "",
 | 
			
		||||
			"sourceRoot": "src",
 | 
			
		||||
			"prefix": "tm",
 | 
			
		||||
			"architect": {
 | 
			
		||||
				"build": {
 | 
			
		||||
					"builder": "@angular-devkit/build-angular:browser",
 | 
			
		||||
					"options": {
 | 
			
		||||
						"allowedCommonJsDependencies": [
 | 
			
		||||
							"@transmute/common"
 | 
			
		||||
						],
 | 
			
		||||
						"outputPath": "dist",
 | 
			
		||||
						"index": "src/index.html",
 | 
			
		||||
						"main": "src/main.ts",
 | 
			
		||||
						"polyfills": [
 | 
			
		||||
							"zone.js"
 | 
			
		||||
						],
 | 
			
		||||
						"tsConfig": "tsconfig.json",
 | 
			
		||||
						"inlineStyleLanguage": "scss",
 | 
			
		||||
						"assets": [
 | 
			
		||||
							"src/assets"
 | 
			
		||||
						],
 | 
			
		||||
						"styles": [
 | 
			
		||||
							"src/styles.scss"
 | 
			
		||||
						],
 | 
			
		||||
						"scripts": []
 | 
			
		||||
					},
 | 
			
		||||
					"configurations": {
 | 
			
		||||
						"production": {
 | 
			
		||||
							"fileReplacements": [
 | 
			
		||||
								{
 | 
			
		||||
									"replace": "src/environments/environment.ts",
 | 
			
		||||
									"with": "src/environments/environment.prod.ts"
 | 
			
		||||
								}
 | 
			
		||||
							],
 | 
			
		||||
							"outputHashing": "all"
 | 
			
		||||
						},
 | 
			
		||||
						"development": {
 | 
			
		||||
							"buildOptimizer": false,
 | 
			
		||||
							"optimization": false,
 | 
			
		||||
							"vendorChunk": true,
 | 
			
		||||
							"extractLicenses": false,
 | 
			
		||||
							"sourceMap": true,
 | 
			
		||||
							"namedChunks": true
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
					"defaultConfiguration": "production"
 | 
			
		||||
				},
 | 
			
		||||
				"serve": {
 | 
			
		||||
					"builder": "@angular-devkit/build-angular:dev-server",
 | 
			
		||||
					"configurations": {
 | 
			
		||||
						"production": {
 | 
			
		||||
							"browserTarget": "transmute-client:build:production"
 | 
			
		||||
						},
 | 
			
		||||
						"development": {
 | 
			
		||||
							"browserTarget": "transmute-client:build:development"
 | 
			
		||||
						}
 | 
			
		||||
					},
 | 
			
		||||
					"defaultConfiguration": "development"
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										12348
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										12348
									
								
								client/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										39
									
								
								client/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								client/package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "@transmute/client",
 | 
			
		||||
	"version": "0.0.0",
 | 
			
		||||
	"scripts": {
 | 
			
		||||
		"ng": "ng",
 | 
			
		||||
		"start": "ng serve",
 | 
			
		||||
		"build": "ng build",
 | 
			
		||||
		"watch": "ng build --watch --configuration development",
 | 
			
		||||
		"test": "ng test"
 | 
			
		||||
	},
 | 
			
		||||
	"dependencies": {
 | 
			
		||||
		"@angular/animations": "^15.2.0",
 | 
			
		||||
		"@angular/cdk": "^15.2.2",
 | 
			
		||||
		"@angular/common": "^15.2.0",
 | 
			
		||||
		"@angular/compiler": "^15.2.0",
 | 
			
		||||
		"@angular/core": "^15.2.0",
 | 
			
		||||
		"@angular/forms": "^15.2.0",
 | 
			
		||||
		"@angular/material": "^15.2.2",
 | 
			
		||||
		"@angular/platform-browser": "^15.2.0",
 | 
			
		||||
		"@angular/platform-browser-dynamic": "^15.2.0",
 | 
			
		||||
		"@angular/router": "^15.2.0",
 | 
			
		||||
		"@transmute/common": "^0.0.0",
 | 
			
		||||
		"bootstrap": "^5.2.3",
 | 
			
		||||
		"ng2-charts": "^4.1.1",
 | 
			
		||||
		"ngx-toastr": "^16.1.0",
 | 
			
		||||
		"rxjs": "~7.8.0",
 | 
			
		||||
		"tslib": "^2.3.0",
 | 
			
		||||
		"zone.js": "~0.12.0"
 | 
			
		||||
	},
 | 
			
		||||
	"devDependencies": {
 | 
			
		||||
		"@angular-devkit/build-angular": "^15.2.2",
 | 
			
		||||
		"@angular/cli": "~15.2.2",
 | 
			
		||||
		"@angular/compiler-cli": "^15.2.0",
 | 
			
		||||
		"typescript": "~4.9.4"
 | 
			
		||||
	},
 | 
			
		||||
	"files": [
 | 
			
		||||
		"dist"
 | 
			
		||||
	]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										37
									
								
								client/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								client/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
 | 
			
		||||
{
 | 
			
		||||
	"compileOnSave": false,
 | 
			
		||||
	"compilerOptions": {
 | 
			
		||||
		"baseUrl": "./",
 | 
			
		||||
		"outDir": "./out-tsc/app",
 | 
			
		||||
		"forceConsistentCasingInFileNames": true,
 | 
			
		||||
		"strict": true,
 | 
			
		||||
		"noImplicitOverride": true,
 | 
			
		||||
		"noPropertyAccessFromIndexSignature": true,
 | 
			
		||||
		"noImplicitReturns": true,
 | 
			
		||||
		"noFallthroughCasesInSwitch": true,
 | 
			
		||||
		"sourceMap": true,
 | 
			
		||||
		"declaration": false,
 | 
			
		||||
		"downlevelIteration": true,
 | 
			
		||||
		"experimentalDecorators": true,
 | 
			
		||||
		"moduleResolution": "node",
 | 
			
		||||
		"importHelpers": true,
 | 
			
		||||
		"target": "ES2022",
 | 
			
		||||
		"module": "ES2022",
 | 
			
		||||
		"useDefineForClassFields": false,
 | 
			
		||||
		"lib": ["dom"],
 | 
			
		||||
		"types": []
 | 
			
		||||
	},
 | 
			
		||||
	"files": [
 | 
			
		||||
		"src/main.ts"
 | 
			
		||||
	],
 | 
			
		||||
	"include": [
 | 
			
		||||
		"src/**/*.d.ts"
 | 
			
		||||
	],
 | 
			
		||||
	"angularCompilerOptions": {
 | 
			
		||||
		"enableI18nLegacyMessageIdFormat": false,
 | 
			
		||||
		"strictInjectionParameters": true,
 | 
			
		||||
		"strictInputAccessModifiers": true,
 | 
			
		||||
		"strictTemplates": true
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user