Merge pull request #3 from Trivernis/fix/angular-peformance

Fix/angular peformance
pull/4/head
Julius Riegel 3 years ago committed by GitHub
commit 5c6f479332
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -11,7 +11,8 @@
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
"style": "scss",
"changeDetection": "OnPush"
},
"@schematics/angular:application": {
"strict": true
@ -67,7 +68,7 @@
"optimization": {
"fonts": false,
"styles": false,
"scripts": true
"scripts": false
},
"vendorChunk": true,
"extractLicenses": false,

@ -40,7 +40,7 @@ checksum = "84450d0b4a8bd1ba4144ce8ce718fbc5d071358b1e5384bace6536b3d1f2d5b3"
[[package]]
name = "app"
version = "0.12.0"
version = "0.13.0"
dependencies = [
"mediarepo-api",
"serde",

@ -4,7 +4,7 @@
<mat-tab [label]="this.selectedRepository? 'RepositoryData' : 'Repositories'">
<app-repositories-tab></app-repositories-tab>
</mat-tab>
<mat-tab *ngFor="let tab of tabs">
<mat-tab *ngFor="let tab of tabs; trackBy: trackByTabId">
<ng-template mat-tab-label>
<div (click)="this.onMouseClickTabLabel(tab, $event)" class="tab-label-div">
{{tab.category}}

@ -27,7 +27,8 @@ export class CoreComponent {
private tabService: TabService,
private repoService: RepositoryService,
private stateService: StateService,
private tagService: TagService) {
private tagService: TagService
) {
this.selectedRepository = this.repoService.selectedRepository.getValue();
this.repoService.selectedRepository.subscribe(async (selected) => {
@ -114,4 +115,8 @@ export class CoreComponent {
await this.closeTab(tab);
}
}
public trackByTabId(index: number, item: TabState) {
return item.uuid;
}
}

@ -73,7 +73,6 @@ export class RepositoriesTabComponent implements OnInit, AfterViewInit {
await this.runRepositoryStartupTasks(dialogContext);
dialogContext.message.next("Restoring previous tabs...");
await this.repoService.loadRepositories();
await this.stateService.loadState();
dialogContext.dialog.close(true);
} catch (err: any) {
this.errorBroker.showError(err);

@ -14,6 +14,7 @@ import {MetadataEntryComponent} from "./metadata-entry/metadata-entry.component"
import {BusyDialogComponent} from "./busy-dialog/busy-dialog.component";
import {SelectableComponent} from "./selectable/selectable.component";
import {MatProgressBarModule} from "@angular/material/progress-bar";
import {HasPropertyPipe} from "./pipes/has-property.pipe";
@NgModule({
@ -26,6 +27,7 @@ import {MatProgressBarModule} from "@angular/material/progress-bar";
MetadataEntryComponent,
BusyDialogComponent,
SelectableComponent,
HasPropertyPipe,
],
exports: [
ConfirmDialogComponent,
@ -35,6 +37,7 @@ import {MatProgressBarModule} from "@angular/material/progress-bar";
InputReceiverDirective,
MetadataEntryComponent,
SelectableComponent,
HasPropertyPipe,
],
imports: [
CommonModule,

@ -1,5 +1,5 @@
<ng-content></ng-content>
<div *ngIf="this.busy" [class.blur]="this.blurBackground" [class.darken]="this.darkenBackground"
class="busy-indicator-overlay">
<mat-progress-spinner [mode]="mode" [value]="value" color="primary"></mat-progress-spinner>
<mat-progress-spinner *ngIf="this.busy" [mode]="mode" [value]="value" color="primary"></mat-progress-spinner>
</div>

@ -22,6 +22,10 @@
background-color: rgba(0, 0, 0, 0.5);
}
hidden {
display: none;
}
::ng-deep app-busy-indicator {
width: 100%;
height: 100%;

@ -1,24 +1,33 @@
import {Component, Input} from "@angular/core";
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges} from "@angular/core";
import {ProgressSpinnerMode} from "@angular/material/progress-spinner";
@Component({
selector: "app-busy-indicator",
templateUrl: "./busy-indicator.component.html",
styleUrls: ["./busy-indicator.component.scss"]
styleUrls: ["./busy-indicator.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BusyIndicatorComponent {
export class BusyIndicatorComponent implements OnChanges {
@Input() busy: boolean = false;
@Input() blurBackground: boolean = false;
@Input() darkenBackground: boolean = false;
@Input() mode: ProgressSpinnerMode = "indeterminate";
@Input() value: number | undefined;
constructor() {
constructor(private changeDetector: ChangeDetectorRef) {
}
public ngOnChanges(changes: SimpleChanges): void {
if (changes["busy"]) {
this.changeDetector.markForCheck();
}
}
public setBusy(busy: boolean) {
if (busy != this.busy) {
this.busy = busy;
this.changeDetector.markForCheck();
}
}
public wrapOperation<T>(operation: Function): T | undefined {
@ -36,6 +45,7 @@ export class BusyIndicatorComponent {
public async wrapAsyncOperation<T>(operation: Function): Promise<T | undefined> {
this.setBusy(true);
console.log("busy");
try {
const result = await operation();
this.setBusy(false);
@ -44,6 +54,7 @@ export class BusyIndicatorComponent {
return undefined;
} finally {
this.setBusy(false);
console.log("not busy");
}
}
}

@ -1,10 +1,11 @@
import {Component, ViewChild,} from "@angular/core";
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewChild,} from "@angular/core";
import {MatMenuTrigger} from "@angular/material/menu";
@Component({
selector: "app-context-menu",
templateUrl: "./context-menu.component.html",
styleUrls: ["./context-menu.component.scss"]
styleUrls: ["./context-menu.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContextMenuComponent {
@ -13,7 +14,7 @@ export class ContextMenuComponent {
@ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger;
constructor() {
constructor(private changeDetector: ChangeDetectorRef) {
}
public onContextMenu(event: MouseEvent) {
@ -22,5 +23,6 @@ export class ContextMenuComponent {
this.y = event.clientY + "px";
this.menuTrigger.menu.focusFirstItem("mouse");
this.menuTrigger.openMenu();
this.changeDetector.markForCheck();
}
}

@ -0,0 +1,8 @@
import { HasPropertyPipe } from './has-property.pipe';
describe('HasPropertyPipe', () => {
it('create an instance', () => {
const pipe = new HasPropertyPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,11 @@
import {Pipe, PipeTransform} from "@angular/core";
@Pipe({
name: "hasProperty"
})
export class HasPropertyPipe implements PipeTransform {
transform(value: any, propertyName: string): unknown {
return propertyName in value;
}
}

@ -1,8 +1,8 @@
<app-image-viewer *ngIf="getContentType() === 'image' && contentUrl" [imageUrl]="contentUrl"></app-image-viewer>
<app-video-viewer *ngIf="getContentType() === 'video' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-video-viewer>
<app-audio-viewer *ngIf="getContentType() === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer>
<app-image-viewer *ngIf="contentType === 'image' && contentUrl" [imageUrl]="contentUrl"></app-image-viewer>
<app-video-viewer *ngIf="contentType === 'video' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-video-viewer>
<app-audio-viewer *ngIf="contentType === 'audio' && this.blobUrl" [blobUrl]="this.blobUrl!"></app-audio-viewer>
<div *ngIf="getContentType() === 'other'" class="download-prompt">
<div *ngIf="contentType === 'other'" class="download-prompt">
<span>Unsupported content type <b>{{this.file.mimeType}}</b></span>
<button (click)="this.downloadContent()" color="primary" mat-flat-button>Download</button>
</div>

@ -1,5 +1,6 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
@ -11,25 +12,23 @@ import {SafeResourceUrl} from "@angular/platform-browser";
import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {
ErrorBrokerService
} from "../../../../services/error-broker/error-broker.service";
import {
BusyIndicatorComponent
} from "../../app-common/busy-indicator/busy-indicator.component";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
type ContentType = "image" | "video" | "audio" | "other";
@Component({
selector: "app-content-viewer",
templateUrl: "./content-viewer.component.html",
styleUrls: ["./content-viewer.component.scss"]
styleUrls: ["./content-viewer.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() file!: File;
public contentUrl: SafeResourceUrl | undefined;
public blobUrl: SafeResourceUrl | undefined;
public contentType: ContentType = "other";
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
@ -40,7 +39,8 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
}
public async ngAfterViewInit() {
if (["audio", "video"].includes(this.getContentType())) {
this.contentType = this.getContentType();
if (["audio", "video"].includes(this.contentType)) {
await this.loadBlobUrl();
} else {
this.contentUrl = this.fileService.buildContentUrl(this.file);
@ -49,8 +49,9 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"]) {
if (["audio", "video"].includes(
this.getContentType()) && this.busyIndicator) {
this.contentType = this.getContentType();
if (["audio", "video"].includes(this.contentType) && this.busyIndicator) {
await this.loadBlobUrl();
} else {
this.contentUrl = this.fileService.buildContentUrl(this.file);
@ -63,23 +64,6 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
this.unloadBlobUrl();
}
public getContentType(): ContentType {
let mimeParts = this.file.mimeType.split("/");
const type = mimeParts.shift() ?? "other";
const subtype = mimeParts.shift() ?? "*";
switch (type) {
case "image":
return "image";
case "video":
return "video";
case "audio":
return "audio";
default:
return "other";
}
}
public async downloadContent() {
const path = await FileHelper.getFileDownloadLocation(this.file);
@ -103,6 +87,22 @@ export class ContentViewerComponent implements AfterViewInit, OnChanges, OnDestr
});
}
private getContentType(): ContentType {
let mimeParts = this.file.mimeType.split("/");
const type = mimeParts.shift() ?? "other";
switch (type) {
case "image":
return "image";
case "video":
return "video";
case "audio":
return "audio";
default:
return "other";
}
}
private unloadBlobUrl() {
if (this.blobUrl) {
URL?.revokeObjectURL(this.blobUrl as string);

@ -1,8 +1,11 @@
<mat-card #card (click)="clickEvent.emit(this)" (dblclick)="dblClickEvent.emit(this)"
[ngClass]="{'selected': entry.selected}">
[class.selected]="this.entry.selected | async">
<mat-card-content>
<app-busy-indicator [busy]="this.loading">
<app-file-thumbnail *ngIf="!loading" [file]="this.entry.data" class=".entry-image"></app-file-thumbnail>
<app-file-thumbnail *ngIf="!loading"
[fileChanged]="this.fileChanged"
[file]="this.entry.data"
class=".entry-image"></app-file-thumbnail>
</app-busy-indicator>
</mat-card-content>
</mat-card>

@ -1,4 +1,6 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
@ -12,28 +14,29 @@ import {
} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {Selectable} from "../../../../models/Selectable";
import {
SchedulingService
} from "../../../../services/scheduling/scheduling.service";
import {SchedulingService} from "../../../../services/scheduling/scheduling.service";
import {BehaviorSubject} from "rxjs";
const LOADING_WORK_KEY = "FILE_THUMBNAIL_LOADING";
@Component({
selector: "app-file-card",
templateUrl: "./file-card.component.html",
styleUrls: ["./file-card.component.scss"]
styleUrls: ["./file-card.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
@ViewChild("card") card!: ElementRef;
@Input() public entry!: Selectable<File>;
@Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined);
@Output() clickEvent = new EventEmitter<FileCardComponent>();
@Output() dblClickEvent = new EventEmitter<FileCardComponent>();
public loading = false;
private cachedId: number | undefined;
private workId: number | undefined;
public loading = false;
constructor(private schedulingService: SchedulingService) {
constructor(private changeDetector: ChangeDetectorRef, private schedulingService: SchedulingService) {
}
async ngOnInit() {
@ -42,10 +45,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
}
async ngOnChanges(changes: SimpleChanges) {
if (changes["file"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) {
if (changes["entry"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) {
this.cachedId = this.entry.data.id;
this.setImageDelayed();
}
if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck());
}
}
public ngOnDestroy(): void {
@ -59,10 +65,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
this.schedulingService.cancelWork(LOADING_WORK_KEY, this.workId);
}
this.loading = true;
this.workId = this.schedulingService.addWork(LOADING_WORK_KEY,
this.workId = this.schedulingService.addWork(
LOADING_WORK_KEY,
async () => {
await this.schedulingService.delay(1);
this.loading = false;
});
this.changeDetector.markForCheck();
}
);
}
}

@ -1,12 +1,12 @@
<app-context-menu #contextMenu>
<ng-content select="[content-before]"></ng-content>
<ng-container *ngIf="this.files">
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionArchive" mat-menu-item>Archive
<button (click)="this.changeFileStatus('Archived')" *ngIf="actionArchive" mat-menu-item>Archive
</button>
<button (click)="this.updateStatus(this.files, 'Imported')" *ngIf="actionImported" mat-menu-item>Back to
<button (click)="this.changeFileStatus('Imported')" *ngIf="actionImported" mat-menu-item>Back to
imported
</button>
<button (click)="this.updateStatus(this.files, 'Deleted')"
<button (click)="this.changeFileStatus('Deleted')"
*ngIf="actionDelete"
mat-menu-item>Delete
</button>
@ -14,7 +14,7 @@
*ngIf="actionDeletePermantently"
mat-menu-item>Delete permanently
</button>
<button (click)="this.updateStatus(this.files, 'Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<button (click)="this.changeFileStatus('Archived')" *ngIf="actionRestore" mat-menu-item>Restore</button>
<!-- everything that only applies to a single file -->
<ng-container>

@ -7,6 +7,7 @@ import {MatDialog, MatDialogRef} from "@angular/material/dialog";
import {BusyDialogComponent} from "../../app-common/busy-dialog/busy-dialog.component";
import {BehaviorSubject} from "rxjs";
import {FileActionBaseComponent} from "../../app-base/file-action-base/file-action-base.component";
import {FileStatus} from "../../../../../api/api-types/files";
type ProgressDialogContext = {
dialog: MatDialogRef<BusyDialogComponent>,
@ -31,6 +32,7 @@ export class FileContextMenuComponent extends FileActionBaseComponent implements
@ViewChild("contextMenu") contextMenu!: ContextMenuComponent;
@Output() fileDeleted = new EventEmitter<File[]>();
@Output() fileStatusChange = new EventEmitter<File[]>();
constructor(fileService: FileService, errorBroker: ErrorBrokerService, dialog: MatDialog) {
super(dialog, errorBroker, fileService);
@ -56,6 +58,11 @@ export class FileContextMenuComponent extends FileActionBaseComponent implements
}
}
public async changeFileStatus(status: FileStatus) {
await this.updateStatus(this.files, status);
this.fileStatusChange.emit(this.files);
}
private applyStatus() {
this.actionDeletePermantently = true;
this.actionDelete = this.actionArchive = this.actionImported = this.actionRestore = false;

@ -13,11 +13,13 @@
<div class="file-scroll-view" fxFlex="20%">
<cdk-virtual-scroll-viewport #virtualScroll class="file-scroll-viewport" itemSize="260" maxBufferPx="3000"
minBufferPx="1000" orientation="horizontal">
<div *cdkVirtualFor="let entry of entries" class="file-item">
<div *cdkVirtualFor="let entry of entries; trackBy: trackByFileId" class="file-item">
<app-file-card (clickEvent)="onEntrySelect($event.entry)"
[entry]="entry"></app-file-card>
[entry]="entry" [fileChanged]="this.fileChanged"></app-file-card>
</div>
</cdk-virtual-scroll-viewport>
</div>
</div>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)"></app-file-context-menu>
<app-file-context-menu #fileContextMenu
(fileDeleted)="this.fileDeleted.emit($event)"
(fileStatusChange)="this.onFileStatusChange()"></app-file-context-menu>

@ -17,6 +17,7 @@ import {Selectable} from "../../../../../models/Selectable";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TabService} from "../../../../../services/tab/tab.service";
import {Key} from "w3c-keys";
import {BehaviorSubject} from "rxjs";
@Component({
selector: "app-file-gallery",
@ -40,11 +41,15 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
public entries: Selectable<File>[] = [];
public selectedFile: Selectable<File> | undefined;
public fileContentUrl: SafeResourceUrl | undefined;
public fileChanged = new BehaviorSubject<void>(undefined);
private scrollTimeout: number | undefined;
private escapeCount = 0;
constructor(private tabService: TabService, private fileService: FileService) {
constructor(
private tabService: TabService,
private fileService: FileService
) {
tabService.selectedTab.subscribe(() => this.adjustElementSizes());
}
@ -173,6 +178,14 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
}
}
public trackByFileId(index: number, item: Selectable<File>) {
return item.data.id;
}
public onFileStatusChange(): void {
this.fileChanged.next();
}
private scrollToSelection(): void {
if (this.selectedFile) {
const selectedIndex = this.entries.indexOf(this.selectedFile);
@ -190,7 +203,6 @@ export class FileGalleryComponent implements OnChanges, OnInit, AfterViewInit {
}
}
private getPreselectedEntry(): Selectable<File> | undefined {
if (this.preselectedFile) {
const entry = this.entries.find(

@ -5,20 +5,20 @@
class="file-gallery-inner">
<cdk-virtual-scroll-viewport #virtualScrollGrid class="file-scroll" itemSize="260" maxBufferPx="2000"
minBufferPx="500">
<div *cdkVirtualFor="let rowEntry of partitionedGridEntries">
<div *cdkVirtualFor="let rowEntry of partitionedGridEntries; trackByFileRowId">
<div class="file-row">
<app-file-card
(clickEvent)="setSelectedFile($event.entry)"
(contextmenu)="this.selectEntryWhenNotSelected(gridEntry); fileContextMenu.onContextMenu($event, this.getSelectedFiles())"
(dblClickEvent)="fileOpen.emit($event.entry.data)"
*ngFor="let gridEntry of rowEntry"
[entry]="gridEntry"></app-file-card>
*ngFor="let gridEntry of rowEntry; trackBy: trackByFileId"
[entry]="gridEntry" [fileChanged]="this.fileChanged"></app-file-card>
</div>
</div>
</cdk-virtual-scroll-viewport>
</div>
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)">
<app-file-context-menu #fileContextMenu (fileDeleted)="this.fileDeleted.emit($event)" (fileStatusChange)="this.onFileStatusChange()">
<button (click)="this.fileOpen.emit(fileContextMenu.files[0])"
*ngIf="fileContextMenu.files.length === 1"
content-before=""

@ -1,5 +1,6 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
@ -17,11 +18,13 @@ import {TabService} from "../../../../../services/tab/tab.service";
import {FileService} from "../../../../../services/file/file.service";
import {Selectable} from "../../../../../models/Selectable";
import {Key} from "w3c-keys";
import {BehaviorSubject} from "rxjs";
@Component({
selector: "app-file-grid",
templateUrl: "./file-grid.component.html",
styleUrls: ["./file-grid.component.scss"]
styleUrls: ["./file-grid.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@ -36,8 +39,10 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
@ViewChild("virtualScrollGrid") virtualScroll!: CdkVirtualScrollViewport;
@ViewChild("inner") inner!: ElementRef<HTMLDivElement>;
selectedEntries: Selectable<File>[] = [];
partitionedGridEntries: Selectable<File>[][] = [];
public fileChanged = new BehaviorSubject<void>(undefined);
public selectedEntries: Selectable<File>[] = [];
public partitionedGridEntries: Selectable<File>[][] = [];
private shiftClicked = false;
private ctrlClicked = false;
private gridEntries: Selectable<File>[] = [];
@ -75,14 +80,14 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
setSelectedFile(clickedEntry: Selectable<File>) {
if (!(this.shiftClicked || this.ctrlClicked) && this.selectedEntries.length > 0) {
this.selectedEntries.forEach(entry => {
if (entry !== clickedEntry) entry.selected = false;
if (entry !== clickedEntry) entry.unselect();
});
this.selectedEntries = [];
}
if (this.shiftClicked && this.selectedEntries.length > 0) {
this.handleShiftSelect(clickedEntry);
} else {
clickedEntry.selected = !clickedEntry.selected;
clickedEntry.selected.next(!clickedEntry.selected.value);
if (!clickedEntry.selected) {
const index = this.selectedEntries.indexOf(clickedEntry);
if (index > -1) {
@ -169,6 +174,18 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.ctrlClicked = event.ctrlKey ? false : this.ctrlClicked;
}
public trackByFileRowId(index: number, item: Selectable<File>[]) {
return item.map(e => e.data.id).join("-");
}
public trackByFileId(index: number, item: Selectable<File>) {
return item.data.id;
}
public onFileStatusChange(): void {
this.fileChanged.next();
}
private setPartitionedGridEntries() {
this.partitionedGridEntries = [];
let scrollToIndex = -1;
@ -196,7 +213,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
this.virtualScroll?.scrollToIndex(scrollToIndex);
if (selectedEntry) {
selectedEntry.selected = true;
selectedEntry.select();
this.selectedEntries.push(selectedEntry);
}
}
@ -208,7 +225,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
const newSelection: Selectable<File>[] = this.gridEntries.filter(
entry => this.selectedEntries.findIndex(
e => e.data.id == entry.data.id) >= 0);
newSelection.forEach(entry => entry.selected = true);
newSelection.forEach(entry => entry.select());
this.selectedEntries = newSelection;
}
@ -223,7 +240,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
for (const gridEntry of this.gridEntries) {
if (found) {
gridEntry.selected = true;
gridEntry.select();
this.selectedEntries.push(gridEntry);
if (gridEntry === clickedEntry || gridEntry == lastEntry) {
return;
@ -231,7 +248,7 @@ export class FileGridComponent implements OnChanges, OnInit, AfterViewInit {
} else if (gridEntry === lastEntry || gridEntry === clickedEntry) {
found = true;
if (gridEntry === clickedEntry) {
gridEntry.selected = true;
gridEntry.select();
this.selectedEntries.push(gridEntry);
}
}

@ -1,14 +1,14 @@
<app-content-aware-image *ngIf="this.getThumbnailSupported() && this.thumbUrl" [imageSrc]="this.thumbUrl"
<app-content-aware-image *ngIf="this.thumbnailSupported && this.thumbUrl" [imageSrc]="this.thumbUrl"
borderRadius="0.25em"></app-content-aware-image>
<div *ngIf="this.getThumbnailSupported() && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="getFileType() === 'video'" name="mat-movie"></ng-icon>
<div *ngIf="this.thumbnailSupported && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="this.file.mimeType === 'image/gif'" class="gif-icon" name="mat-gif"></ng-icon>
</div>
<div *ngIf="!this.getThumbnailSupported() || !this.thumbUrl" class="file-type-icon">
<ng-icon *ngIf="getFileType() === 'image'" name="mat-image"></ng-icon>
<ng-icon *ngIf="getFileType() === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="getFileType() === 'audio'" name="mat-audiotrack"></ng-icon>
<ng-icon *ngIf="getFileType() === 'text'" name="mat-description"></ng-icon>
<div *ngIf="!this.thumbnailSupported || !this.thumbUrl" class="file-type-icon">
<ng-icon *ngIf="fileType === 'image'" name="mat-image"></ng-icon>
<ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>
<ng-icon *ngIf="fileType === 'audio'" name="mat-audiotrack"></ng-icon>
<ng-icon *ngIf="fileType === 'text'" name="mat-description"></ng-icon>
</div>
<div *ngIf="file.status !== 'Archived'" class="file-status-icon">
<ng-icon *ngIf="file.status === 'Deleted'" name="mat-auto-delete"></ng-icon>

@ -1,45 +1,74 @@
import {AfterViewInit, Component, Input, OnChanges, SimpleChanges} from "@angular/core";
import {
AfterViewChecked,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
SimpleChanges
} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service";
import {FileHelper} from "../../../../services/file/file.helper";
import {SafeResourceUrl} from "@angular/platform-browser";
import {BehaviorSubject} from "rxjs";
@Component({
selector: "app-file-thumbnail",
templateUrl: "./file-thumbnail.component.html",
styleUrls: ["./file-thumbnail.component.scss"]
styleUrls: ["./file-thumbnail.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileThumbnailComponent implements OnChanges, AfterViewInit {
export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterViewChecked {
@Input() file!: File;
@Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined);
public thumbUrl: SafeResourceUrl | undefined;
public fileType!: string;
public thumbnailSupported: boolean = false;
private supportedThumbnailTypes = ["image", "video"];
private previousStatus = "imported";
constructor(private fileService: FileService) {
constructor(private changeDetector: ChangeDetectorRef, private fileService: FileService) {
}
public async ngAfterViewInit() {
if (this.thumbnailSupported) {
this.thumbUrl = this.fileService.buildThumbnailUrl(this.file, 250, 250);
}
}
public ngAfterViewChecked(): void {
if (this.file && this.file.status != this.previousStatus) {
this.previousStatus = this.file.status;
this.changeDetector.markForCheck();
}
}
public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"]) {
this.thumbUrl = this.fileService.buildThumbnailUrl(this.file,
250, 250
);
this.fileType = this.getFileType();
this.thumbnailSupported = this.getThumbnailSupported();
}
if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck());
}
}
public getThumbnailSupported(): boolean {
private getThumbnailSupported(): boolean {
const mimeParts = FileHelper.parseMime(this.file.mimeType);
return !!mimeParts && this.supportedThumbnailTypes.includes(
mimeParts[0]);
}
public getFileType(): string {
private getFileType(): string {
const mimeParts = FileHelper.parseMime(this.file.mimeType);
return (mimeParts && mimeParts[0]) ?? "other";
}

@ -1,4 +1,4 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {Observable} from "rxjs";
import {FormControl} from "@angular/forms";
import {Tag} from "../../../../../api/models/Tag";
@ -15,7 +15,8 @@ type AutocompleteEntry = {
@Component({
selector: "app-filter-input",
templateUrl: "./filter-input.component.html",
styleUrls: ["./filter-input.component.scss"]
styleUrls: ["./filter-input.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputComponent implements OnChanges {

@ -3,15 +3,21 @@
<h1>File Metadata</h1>
<mat-divider></mat-divider>
</div>
<app-busy-indicator [blurBackground]="true" [busy]="this.loading" [darkenBackground]="false">
<app-busy-indicator [blurBackground]="true" [darkenBackground]="false">
<div class="file-metadata-entries-scroll-container">
<div class="file-metadata-entries">
<app-editable-metadata-entry *ngIf="fileMetadata" attributeName="Name" [value]="fileMetadata.name ?? ''" (valueChangeEvent)="this.saveFileName($event)"></app-editable-metadata-entry>
<app-editable-metadata-entry (valueChangeEvent)="this.saveFileName($event)"
*ngIf="fileMetadata"
[value]="fileMetadata.name ?? ''"
attributeName="Name"></app-editable-metadata-entry>
<app-metadata-entry attributeName="Content Descriptor (CD)">{{file.cd}}</app-metadata-entry>
<app-metadata-entry attributeName="Mime Type">{{file.mimeType}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Imported at">{{fileMetadata.import_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Created at">{{fileMetadata.creation_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata" attributeName="Changed at">{{fileMetadata.change_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata"
attributeName="Imported at">{{fileMetadata.import_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata"
attributeName="Created at">{{fileMetadata.creation_time.toLocaleString()}}</app-metadata-entry>
<app-metadata-entry *ngIf="fileMetadata"
attributeName="Changed at">{{fileMetadata.change_time.toLocaleString()}}</app-metadata-entry>
</div>
</div>
</app-busy-indicator>

@ -1,48 +1,45 @@
import {
Component,
Input,
OnChanges,
OnInit,
SimpleChanges
} from "@angular/core";
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild} from "@angular/core";
import {File} from "../../../../../api/models/File";
import {FileService} from "../../../../services/file/file.service";
import {FileMetadata} from "../../../../../api/api-types/files";
import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
@Component({
selector: "app-file-metadata",
templateUrl: "./file-metadata.component.html",
styleUrls: ["./file-metadata.component.scss"]
styleUrls: ["./file-metadata.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileMetadataComponent implements OnInit, OnChanges {
@Input() file!: File;
public fileMetadata: FileMetadata | undefined;
public loading = false;
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
constructor(private fileService: FileService) {
}
public async ngOnInit() {
this.loading = true;
await this.busyIndicator.wrapAsyncOperation(async () => {
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false;
});
}
public async ngOnChanges(changes: SimpleChanges) {
if (changes["file"] && (!this.fileMetadata || this.fileMetadata.file_id != this.file.id)) {
this.loading = true;
await this.busyIndicator.wrapAsyncOperation(async () => {
this.fileMetadata = await this.fileService.getFileMetadata(this.file.id);
this.loading = false;
});
}
}
public async saveFileName(name: string) {
this.loading = true;
await this.busyIndicator.wrapAsyncOperation(async () => {
const newFile = await this.fileService.updateFileName(this.file.id, name);
if (this.fileMetadata) {
this.fileMetadata.name = newFile.name;
}
this.loading = false;
});
}
}

@ -50,7 +50,7 @@
<cdk-virtual-scroll-viewport itemSize="50" maxBufferPx="4000" minBufferPx="500">
<div (click)="addTagFilter(tag.getNormalizedOutput())"
(contextmenu)="contextMenuTag = tag; contextMenu.onContextMenu($event)"
*cdkVirtualFor="let tag of contextTags" class="selectable-tag">
*cdkVirtualFor="let tag of contextTags; trackBy: trackByTagId" class="selectable-tag">
<app-tag-item [tag]="tag"></app-tag-item>
</div>
</cdk-virtual-scroll-viewport>

@ -1,4 +1,14 @@
import {AfterViewChecked, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from "@angular/core";
import {
AfterViewChecked,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from "@angular/core";
import {SortKey} from "../../../../models/SortKey";
import {MatDialog} from "@angular/material/dialog";
import {SortDialogComponent} from "./sort-dialog/sort-dialog.component";
@ -18,7 +28,8 @@ import * as deepEqual from "fast-deep-equal";
@Component({
selector: "app-file-search",
templateUrl: "./file-search.component.html",
styleUrls: ["./file-search.component.scss"]
styleUrls: ["./file-search.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileSearchComponent implements AfterViewChecked, OnInit {
public sortExpression: SortKey[] = [];
@ -59,7 +70,6 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
});
this.state.sortKeys.subscribe(s => this.sortExpression = s);
this.applyStatusFromFilters();
await this.searchForFiles();
this.needsScroll = true;
this.assignDisplayedFilters();
}
@ -187,6 +197,10 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
return deepEqual(tagFilter, filter);
}
public trackByTagId(index: number, item: Tag) {
return item.id;
}
private assignDisplayedFilters() {
this.displayedFilters = this.filters.getFilters().filter(f => !this.isTagFilter(f));
}

@ -1,4 +1,4 @@
import {Component, Inject, OnChanges, SimpleChanges} from "@angular/core";
import {ChangeDetectionStrategy, Component, Inject, OnChanges, SimpleChanges} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {SortDialogComponent} from "../sort-dialog/sort-dialog.component";
import {Tag} from "../../../../../../api/models/Tag";
@ -13,7 +13,8 @@ type IndexableSelection<T> = {
@Component({
selector: "app-filter-dialog",
templateUrl: "./filter-dialog.component.html",
styleUrls: ["./filter-dialog.component.scss"]
styleUrls: ["./filter-dialog.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterDialogComponent implements OnChanges {
public availableTags: Tag[] = [];

@ -7,10 +7,10 @@
<app-selectable #componentSelectable
(appSelect)="this.entrySelect.emit(entry)"
(appUnselect)="this.entryUnselect.emit(entry)">
<app-property-query-item *ngIf="this.queryIs(entry[1], 'Property')"
[propertyQuery]="this.propertyQuery(entry[1]).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(entry[1], 'Tag')"
[tagQuery]="this.tagQuery(entry[1]).Tag"></app-tag-query-item>
<app-property-query-item *ngIf="entry[1] | hasProperty: 'Property'"
[propertyQuery]="entry[1] | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="entry[1] | hasProperty: 'Tag'"
[tagQuery]="entry[1] | getTagQuery"></app-tag-query-item>
</app-selectable>
</mat-list-item>
</mat-list>
@ -19,9 +19,9 @@
<app-selectable #singleSelectable
(appSelect)="this.appSelect.emit(this.query)"
(appUnselect)="this.appUnselect.emit(this.query)">
<app-property-query-item *ngIf="this.queryIs(this.query, 'Property')"
[propertyQuery]="this.propertyQuery(this.query).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(this.query, 'Tag')"
[tagQuery]="this.tagQuery(this.query).Tag"></app-tag-query-item>
<app-property-query-item *ngIf="this.query | hasProperty: 'Property'"
[propertyQuery]="this.query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.query | hasProperty: 'Tag'"
[tagQuery]="this.query | getTagQuery"></app-tag-query-item>
</app-selectable>
</span>

@ -1,16 +1,12 @@
import {Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {
FilterExpression,
FilterQuery,
FilterQueryProperty,
FilterQueryTag
} from "../../../../../../../api/api-types/files";
import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges} from "@angular/core";
import {FilterExpression, FilterQuery} from "../../../../../../../api/api-types/files";
import {enumerate} from "../../../../../../utils/list-utils";
@Component({
selector: "app-filter-expression-list-item",
templateUrl: "./filter-expression-list-item.component.html",
styleUrls: ["./filter-expression-list-item.component.scss"]
styleUrls: ["./filter-expression-list-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterExpressionListItemComponent implements OnChanges {
@ -35,18 +31,6 @@ export class FilterExpressionListItemComponent implements OnChanges {
}
}
public queryIs(query: FilterQuery, key: "Property" | "Tag"): boolean {
return key in query;
}
public propertyQuery(query: FilterQuery): FilterQueryProperty {
return query as FilterQueryProperty;
}
public tagQuery(query: FilterQuery): FilterQueryTag {
return query as FilterQueryTag;
}
private parseFilter() {
if (this.filter && "OrExpression" in this.filter) {
this.orExpression = enumerate(this.filter.OrExpression);

@ -1,15 +1,15 @@
<span *ngIf="is('OrExpression')" class="or-expression">
<ng-container *ngFor="let query of this.orExpression().OrExpression">
<app-property-query-item *ngIf="this.queryIs(query, 'Property')"
[propertyQuery]="this.propertyQuery(query).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(query, 'Tag')"
[tagQuery]="this.tagQuery(query).Tag"></app-tag-query-item>
<span *ngIf="this.orExpression" class="or-expression">
<ng-container *ngFor="let query of this.orExpression">
<app-property-query-item *ngIf="query | hasProperty: 'Property'"
[propertyQuery]="query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="query | hasProperty: 'Tag'"
[tagQuery]="query | getTagQuery"></app-tag-query-item>
<span class="or-combinator"> OR </span>
</ng-container>
</span>
<span *ngIf="is('Query')" class="query">
<app-property-query-item *ngIf="this.queryIs(this.query().Query, 'Property')"
[propertyQuery]="this.propertyQuery(this.query().Query).Property"></app-property-query-item>
<app-tag-query-item *ngIf="this.queryIs(this.query().Query, 'Tag')"
[tagQuery]="this.tagQuery(this.query().Query).Tag"></app-tag-query-item>
<span *ngIf="this.query" class="query">
<app-property-query-item *ngIf="this.query | hasProperty: 'Property'"
[propertyQuery]="this.query | getPropertyQuery"></app-property-query-item>
<app-tag-query-item *ngIf="this.query | hasProperty: 'Tag'"
[tagQuery]="this.query | getTagQuery"></app-tag-query-item>
</span>

@ -1,47 +1,35 @@
import {Component, Input} from "@angular/core";
import {
FilterExpression,
FilterExpressionOrExpression,
FilterExpressionQuery,
FilterQuery,
FilterQueryProperty,
FilterQueryTag
} from "../../../../../../api/api-types/files";
import {ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges} from "@angular/core";
import {FilterExpression, FilterQuery} from "../../../../../../api/api-types/files";
@Component({
selector: "app-filter-expression-item",
templateUrl: "./filter-expression-item.component.html",
styleUrls: ["./filter-expression-item.component.scss"]
styleUrls: ["./filter-expression-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FilterExpressionItemComponent {
export class FilterExpressionItemComponent implements OnInit, OnChanges {
@Input() filter!: FilterExpression;
public orExpression?: FilterQuery[];
public query?: FilterQuery;
constructor() {
}
public is(key: "OrExpression" | "Query"): boolean {
return key in this.filter;
}
public orExpression(): FilterExpressionOrExpression {
return this.filter as FilterExpressionOrExpression;
public ngOnInit(): void {
this.parseQuery();
}
public query(): FilterExpressionQuery {
return this.filter as FilterExpressionQuery;
public ngOnChanges(changes: SimpleChanges): void {
if (changes["filter"]) {
this.parseQuery();
}
public queryIs(query: FilterQuery, key: "Property" | "Tag"): boolean {
return key in query;
}
public propertyQuery(query: FilterQuery): FilterQueryProperty {
return query as FilterQueryProperty;
private parseQuery() {
if ("Query" in this.filter) {
this.query = this.filter.Query;
} else {
this.orExpression = this.filter.OrExpression;
}
public tagQuery(query: FilterQuery): FilterQueryTag {
return query as FilterQueryTag;
}
}

@ -0,0 +1,8 @@
import { GetPropertyQueryPipe } from './get-property-query.pipe';
describe('GetPropertyQueryPipe', () => {
it('create an instance', () => {
const pipe = new GetPropertyQueryPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,13 @@
import {Pipe, PipeTransform} from "@angular/core";
import {FilterQuery, FilterQueryProperty, PropertyQuery} from "../../../../../../api/api-types/files";
@Pipe({
name: "getPropertyQuery"
})
export class GetPropertyQueryPipe implements PipeTransform {
transform(value: FilterQuery): PropertyQuery {
return (value as FilterQueryProperty).Property;
}
}

@ -0,0 +1,8 @@
import { GetTagQueryPipe } from './get-tag-query.pipe';
describe('GetTagQueryPipe', () => {
it('create an instance', () => {
const pipe = new GetTagQueryPipe();
expect(pipe).toBeTruthy();
});
});

@ -0,0 +1,12 @@
import {Pipe, PipeTransform} from "@angular/core";
import {FilterQuery, FilterQueryTag, TagQuery} from "../../../../../../api/api-types/files";
@Pipe({
name: "getTagQuery"
})
export class GetTagQueryPipe implements PipeTransform {
transform(value: FilterQuery): TagQuery {
return (value as FilterQueryTag).Tag;
}
}

@ -1,14 +1,16 @@
import {Component, Inject} from "@angular/core";
import {ChangeDetectionStrategy, Component, Inject} from "@angular/core";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {SortKey} from "../../../../../models/SortKey";
import {CdkDragDrop, moveItemInArray} from "@angular/cdk/drag-drop";
import {Namespace} from "../../../../../../api/models/Namespace";
import {TagService} from "../../../../../services/tag/tag.service";
import {compareSearchResults} from "../../../../../utils/compare-utils";
@Component({
selector: "app-sort-dialog",
templateUrl: "./sort-dialog.component.html",
styleUrls: ["./sort-dialog.component.scss"]
styleUrls: ["./sort-dialog.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SortDialogComponent {
@ -24,20 +26,6 @@ export class SortDialogComponent {
namespaces => this.namespaces = namespaces);
}
private static compareSuggestionNamespaces(query: string, l: string, r: string): number {
if (l.startsWith(query) && !r.startsWith(query)) {
return -1;
} else if (!l.startsWith(query) && r.startsWith(query)) {
return 1;
} else if (l.length < r.length) {
return -1;
} else if (l.length > r.length) {
return 1;
} else {
return l.localeCompare(r);
}
}
addNewSortKey() {
const sortKey = new SortKey("FileName", "Ascending", undefined);
this.sortEntries.push(sortKey);
@ -64,7 +52,7 @@ export class SortDialogComponent {
public updateAutocompleteSuggestions(value: string): void {
this.suggestedNamespaces = this.namespaces.sort(
(a, b) => SortDialogComponent.compareSuggestionNamespaces(value, a.name, b.name))
(a, b) => compareSearchResults(value, a.name, b.name))
.slice(0, 50);
}
}

@ -46,6 +46,8 @@ import {
import {
FilterExpressionListItemComponent
} from "./file-search/filter-dialog/filter-expression-list-item/filter-expression-list-item.component";
import { GetTagQueryPipe } from './file-search/filter-pipes/get-tag-query.pipe';
import { GetPropertyQueryPipe } from './file-search/filter-pipes/get-property-query.pipe';
@NgModule({
@ -62,6 +64,8 @@ import {
TagQueryItemComponent,
PropertyQueryItemComponent,
FilterExpressionListItemComponent,
GetTagQueryPipe,
GetPropertyQueryPipe,
],
exports: [
TagEditComponent,

@ -1,11 +1,11 @@
<div class="file-edit-inner" fxLayout="column">
<app-busy-indicator [blurBackground]="true" class="file-edit-inner" fxLayout="column">
<div class="file-edit-header" fxFlex="100px">
<h1>Edit Tags</h1>
<mat-divider></mat-divider>
</div>
<div class="tag-edit-list" fxFlex fxFlexAlign="start" fxFlexFill>
<cdk-virtual-scroll-viewport #tagScroll itemSize="50" maxBufferPx="2000" minBufferPx="1000">
<div *cdkVirtualFor="let tag of tags" class="editable-tag">
<div *cdkVirtualFor="let tag of tags; trackBy: trackByTagId" class="editable-tag">
<app-tag-item [tag]="tag"></app-tag-item>
<button (click)="removeTag(tag)" class="tag-remove-button" mat-icon-button>
<ng-icon name="mat-remove"></ng-icon>
@ -33,5 +33,4 @@
</mat-select>
</mat-form-field>
</div>
<app-busy-indicator *ngIf="this.loading" [busy]="this.loading" [blurBackground]="true"></app-busy-indicator>
</div>
</app-busy-indicator>

@ -12,6 +12,7 @@
.file-edit-header {
text-align: center;
h1 {
margin-top: 20px;
}
@ -75,6 +76,7 @@ mat-divider {
width: 100%;
}
/*
app-busy-indicator {
position: absolute;
top: 0;
@ -83,3 +85,4 @@ app-busy-indicator {
width: 100%;
z-index: 99;
}
*/

@ -1,9 +1,10 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild
@ -12,31 +13,35 @@ import {File} from "../../../../../api/models/File";
import {Tag} from "../../../../../api/models/Tag";
import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling";
import {TagService} from "../../../../services/tag/tag.service";
import {ErrorBrokerService} from "../../../../services/error-broker/error-broker.service";
import {BusyIndicatorComponent} from "../../app-common/busy-indicator/busy-indicator.component";
@Component({
selector: "app-tag-edit",
templateUrl: "./tag-edit.component.html",
styleUrls: ["./tag-edit.component.scss"]
styleUrls: ["./tag-edit.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TagEditComponent implements OnInit, OnChanges {
export class TagEditComponent implements AfterViewInit, OnChanges {
@Input() files: File[] = [];
@Output() tagEditEvent = new EventEmitter<TagEditComponent>();
public tags: Tag[] = [];
@ViewChild("tagScroll") tagScroll!: CdkVirtualScrollViewport;
@ViewChild(BusyIndicatorComponent) busyIndicator!: BusyIndicatorComponent;
public tags: Tag[] = [];
public allTags: Tag[] = [];
public editMode: string = "Toggle";
@ViewChild("tagScroll") tagScroll!: CdkVirtualScrollViewport;
private fileTags: { [key: number]: Tag[] } = {};
public loading = false;
constructor(
private errorBroker: ErrorBrokerService,
private tagService: TagService,
) {
}
async ngOnInit() {
async ngAfterViewInit() {
this.tagService.tags.subscribe(tags => this.allTags = tags);
await this.tagService.loadTags();
await this.tagService.loadNamespaces();
@ -50,7 +55,6 @@ export class TagEditComponent implements OnInit, OnChanges {
}
public async editTag(tag: string): Promise<void> {
this.loading = true;
if (tag.length > 0) {
let tagInstance = this.allTags.find(
t => t.getNormalizedOutput() === tag);
@ -71,10 +75,10 @@ export class TagEditComponent implements OnInit, OnChanges {
break;
}
}
this.loading = false;
}
async toggleTag(tag: Tag) {
await this.wrapAsyncOperation(async () => {
for (const file of this.files) {
const fileTags = this.fileTags[file.id];
let addedTags = [];
@ -86,7 +90,8 @@ export class TagEditComponent implements OnInit, OnChanges {
}
this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id,
addedTags, removedTags);
addedTags, removedTags
);
if (addedTags.length > 0) {
await this.tagService.loadTags();
await this.tagService.loadNamespaces();
@ -97,15 +102,18 @@ export class TagEditComponent implements OnInit, OnChanges {
if (index >= 0) {
this.tagScroll.scrollToIndex(index);
}
});
this.tagEditEvent.emit(this);
}
async addTag(tag: Tag) {
await this.wrapAsyncOperation(async () => {
for (const file of this.files) {
if ((this.fileTags[file.id] ?? []).findIndex(t => t.id === tag.id) < 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id,
[tag.id], []);
[tag.id], []
);
}
}
this.mapFileTagsToTagList();
@ -113,27 +121,33 @@ export class TagEditComponent implements OnInit, OnChanges {
if (index >= 0) {
this.tagScroll.scrollToIndex(index);
}
this.tagEditEvent.emit(this);
await this.tagService.loadTags();
await this.tagService.loadNamespaces();
});
this.tagEditEvent.emit(this);
}
public async removeTag(tag: Tag) {
this.loading = true;
await this.wrapAsyncOperation(async () => {
for (const file of this.files) {
if (this.fileTags[file.id].findIndex(t => t.id === tag.id) >= 0) {
this.fileTags[file.id] = await this.tagService.changeFileTags(
file.id,
[], [tag.id]);
[], [tag.id]
);
}
}
this.mapFileTagsToTagList();
this.loading = false;
});
this.tagEditEvent.emit(this);
}
public trackByTagId(index: number, item: Tag) {
return item.id;
}
private async loadFileTags() {
this.loading = true;
await this.wrapAsyncOperation(async () => {
const promises = [];
const loadFn = async (file: File) => {
this.fileTags[file.id] = await this.tagService.getTagsForFiles(
@ -145,7 +159,7 @@ export class TagEditComponent implements OnInit, OnChanges {
await Promise.all(promises);
this.mapFileTagsToTagList();
this.loading = false;
});
}
private mapFileTagsToTagList() {
@ -160,4 +174,17 @@ export class TagEditComponent implements OnInit, OnChanges {
(a, b) => a.getNormalizedOutput()
.localeCompare(b.getNormalizedOutput()));
}
private async wrapAsyncOperation<T>(cb: () => Promise<T>): Promise<T | undefined> {
if (!this.busyIndicator) {
try {
return cb();
} catch (err: any) {
this.errorBroker.showError(err);
return undefined;
}
} else {
return this.busyIndicator.wrapAsyncOperation(cb);
}
}
}

@ -1,12 +1,18 @@
import {BehaviorSubject} from "rxjs";
export class Selectable<T> {
constructor(public data: T, public selected: boolean) {
public selected: BehaviorSubject<boolean>;
constructor(public data: T, selected: boolean) {
this.selected = new BehaviorSubject<boolean>(selected);
}
public select() {
this.selected = true;
this.selected.next(true);
}
public unselect() {
this.selected = false;
this.selected.next(false);
}
}

Loading…
Cancel
Save