diff --git a/mediarepo-ui/src/app/app.module.ts b/mediarepo-ui/src/app/app.module.ts index ffaf063..ea345f8 100644 --- a/mediarepo-ui/src/app/app.module.ts +++ b/mediarepo-ui/src/app/app.module.ts @@ -54,6 +54,8 @@ import { ImportTabSidebarComponent } from './pages/home/import-tab/import-tab-si import { NativeFileSelectComponent } from './components/inputs/native-file-select/native-file-select.component'; import { FilesystemImportComponent } from './pages/home/import-tab/import-tab-sidebar/filesystem-import/filesystem-import.component'; import {MatCheckboxModule} from "@angular/material/checkbox"; +import { FilterDialogComponent } from './components/file-search/filter-dialog/filter-dialog.component'; +import { TagFilterListItemComponent } from './components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component'; @NgModule({ declarations: [ @@ -78,6 +80,8 @@ import {MatCheckboxModule} from "@angular/material/checkbox"; ImportTabSidebarComponent, NativeFileSelectComponent, FilesystemImportComponent, + FilterDialogComponent, + TagFilterListItemComponent, ], imports: [ BrowserModule, diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.html b/mediarepo-ui/src/app/components/file-search/file-search.component.html index 63ce7e1..cfd3ae7 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.html +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.html @@ -16,6 +16,9 @@ [formControl]="formControl" [matAutocomplete]="auto" matInput/> + {{tag}} diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.scss b/mediarepo-ui/src/app/components/file-search/file-search.component.scss index 15a087f..81993c7 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.scss +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.scss @@ -54,3 +54,9 @@ cursor: pointer; white-space: nowrap; } + +.filter-dialog-button { + position: absolute; + right: 0; + top: -20px; +} diff --git a/mediarepo-ui/src/app/components/file-search/file-search.component.ts b/mediarepo-ui/src/app/components/file-search/file-search.component.ts index 2e9c991..7d77655 100644 --- a/mediarepo-ui/src/app/components/file-search/file-search.component.ts +++ b/mediarepo-ui/src/app/components/file-search/file-search.component.ts @@ -23,6 +23,7 @@ import { FilterExpression, SingleFilterExpression } from "../../models/FilterExpression"; +import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component"; @Component({ @@ -70,12 +71,9 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { } public addSearchTag(tag: string) { - if (tag.startsWith("-")) { - tag = tag.replace(/^-/g, ''); - this.filters.push(new SingleFilterExpression(new TagQuery(tag, true))); - } else { - this.filters.push(new SingleFilterExpression(new TagQuery(tag, false))); - } + this.filters.push(new SingleFilterExpression(TagQuery.fromString(tag))); + tag = tag.replace(/^-/g, ''); + if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { const index = this.filters.findIndex(t => t.partiallyEq(tag)); this.filters.splice(index, 1); @@ -143,4 +141,22 @@ export class FileSearchComponent implements AfterViewChecked, OnInit { .map(t => negated ? "-" + t : t) .slice(0, 20); } + + public openFilterDialog(): void { + const filterEntries = this.filters.map(f => f.clone()); + const filterDialog = this.dialog.open(FilterDialogComponent, { + minWidth: "25vw", + data: { + filterEntries, + validTags: this.validTags, + }, + disableClose: true, + }); + filterDialog.afterClosed().subscribe(async (filterExpression) => { + if (filterExpression !== undefined || filterExpression?.length > 0) { + this.filters = filterExpression; + await this.searchForFiles(); + } + }); + } } diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html new file mode 100644 index 0000000..6b734e8 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html @@ -0,0 +1,22 @@ +

Filters

+
+ + + + + + + + Enter tags to filter for + + + + {{tag}} + + + +
+
+ + +
diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss new file mode 100644 index 0000000..49c51ef --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss @@ -0,0 +1,22 @@ +.dialog-actions { + display: flex; + flex-direction: row-reverse; + width: 100%; + + button { + margin-left: 1em; + } +} + +.tag-input { + width: 100%; +} + +mat-list-item.filter-list-item { + height: 100%; + padding: 0.5em 0; +} + +app-tag-filter-list-item { + width: 100%; +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts new file mode 100644 index 0000000..7c3bedb --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilterDialogComponent } from './filter-dialog.component'; + +describe('FilterDialogComponent', () => { + let component: FilterDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FilterDialogComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FilterDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts new file mode 100644 index 0000000..8a10146 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts @@ -0,0 +1,108 @@ +import { + Component, + ElementRef, + HostListener, + Inject, + ViewChild +} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog"; +import {SortDialogComponent} from "../sort-dialog/sort-dialog.component"; +import { + FilterExpression, OrFilterExpression, + SingleFilterExpression +} from "../../../models/FilterExpression"; +import {Observable} from "rxjs"; +import {FormControl} from "@angular/forms"; +import {last, map, startWith} from "rxjs/operators"; +import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete"; +import {TagQuery} from "../../../models/TagQuery"; + +@Component({ + selector: 'app-filter-dialog', + templateUrl: './filter-dialog.component.html', + styleUrls: ['./filter-dialog.component.scss'] +}) +export class FilterDialogComponent { + + public filters: FilterExpression[]; + public suggestionTags: Observable; + public validTags: string[] = []; + public formControl = new FormControl(); + public mode: "AND" | "OR" = "AND"; + @ViewChild("tagInput") tagInput!: ElementRef; + + constructor(public dialogRef: MatDialogRef, @Inject( + MAT_DIALOG_DATA) data: any) { + this.filters = data.filterEntries; + this.validTags = data.validTags; + + this.suggestionTags = this.formControl.valueChanges.pipe(startWith(null), + map( + (tag: string | null) => tag ? this.filterSuggestionTag( + tag) : this.validTags.slice(0, 20))); + } + + public cancelFilter(): void { + this.dialogRef.close(); + } + + public confirmFilter(): void { + this.dialogRef.close(this.filters); + } + + private filterSuggestionTag(tag: string) { + const negated = tag.startsWith("-"); + const normalizedTag = tag.replace(/^-/, ""); + + return this.validTags.filter( + t => t.includes(normalizedTag) && this.filters.findIndex( + f => f.eq(t)) < 0) + .map(t => negated ? "-" + t : t) + .slice(0, 20); + } + + public addFilterByAutocomplete(event: MatAutocompleteSelectedEvent): void { + this.addFilter(event.option.value); + this.formControl.setValue(null); + this.tagInput.nativeElement.value = ''; + } + + public addFilterByInput(): void { + this.addFilter(this.formControl.value); + this.formControl.setValue(null); + this.tagInput.nativeElement.value = ''; + } + + public addFilter(tag: string) { + const query = TagQuery.fromString(tag); + + if (this.mode === "AND") { + this.filters.push(new SingleFilterExpression(query)); + tag = tag.replace(/^-/g, ''); + + if (this.filters.filter(t => t.partiallyEq(tag)).length > 1) { + const index = this.filters.findIndex(t => t.partiallyEq(tag)); + this.filters.splice(index, 1); + } + } else { + let queryList = this.filters.pop()?.queryList() ?? []; + + queryList.push(query); + this.filters.push(new OrFilterExpression(queryList)); + } + } + + @HostListener("window:keydown", ["$event"]) + private async handleKeydownEvent(event: KeyboardEvent) { + if (event.key === "Shift") { + this.mode = "OR"; + } + } + + @HostListener("window:keyup", ["$event"]) + private async handleKeyupEvent(event: KeyboardEvent) { + if (event.key === "Shift") { + this.mode = "AND"; + } + } +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html new file mode 100644 index 0000000..2682840 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.html @@ -0,0 +1,16 @@ + + {{expression.getDisplayName()}} + + +
+ + + {{query.getNormalizedTag()}} + + + +
diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss new file mode 100644 index 0000000..4e0e0a9 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.scss @@ -0,0 +1,16 @@ +.remove-button { + position: absolute; + top: calc(0.5em - 15px); + right: 0; +} + +mat-list { + height: 100%; + width: 100%; +} + +mat-list-item.or-filter-list-item { + padding: 0.5em 0; + height: 100%; + width: 100%; +} diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts new file mode 100644 index 0000000..c6a054a --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TagFilterListItemComponent } from './tag-filter-list-item.component'; + +describe('TagFilterListItemComponent', () => { + let component: TagFilterListItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ TagFilterListItemComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TagFilterListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts new file mode 100644 index 0000000..7997d47 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/tag-filter-list-item/tag-filter-list-item.component.ts @@ -0,0 +1,18 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {FilterExpression} from "../../../../models/FilterExpression"; + +@Component({ + selector: 'app-tag-filter-list-item', + templateUrl: './tag-filter-list-item.component.html', + styleUrls: ['./tag-filter-list-item.component.scss'] +}) +export class TagFilterListItemComponent { + + @Input() expression!: FilterExpression; + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/mediarepo-ui/src/app/models/FilterExpression.ts b/mediarepo-ui/src/app/models/FilterExpression.ts index 33ac090..391877d 100644 --- a/mediarepo-ui/src/app/models/FilterExpression.ts +++ b/mediarepo-ui/src/app/models/FilterExpression.ts @@ -9,6 +9,10 @@ export interface FilterExpression { partiallyEq(value: any): boolean; getDisplayName(): string; + + clone(): FilterExpression; + + queryList(): TagQuery[]; } export class OrFilterExpression implements FilterExpression{ @@ -30,6 +34,15 @@ export class OrFilterExpression implements FilterExpression{ public getDisplayName(): string { return this.filter.map(t => t.getNormalizedTag()).join(" OR "); } + + public clone(): OrFilterExpression { + let tags = this.filter.map((t: TagQuery) => new TagQuery(t.tag, t.negate)); + return new OrFilterExpression(tags) + } + + public queryList(): TagQuery[] { + return this.filter; + } } export class SingleFilterExpression implements FilterExpression { @@ -51,4 +64,12 @@ export class SingleFilterExpression implements FilterExpression { public getDisplayName(): string { return this.filter.getNormalizedTag(); } + + public clone(): FilterExpression { + return new SingleFilterExpression(new TagQuery(this.filter.tag, this.filter.negate)) + } + + public queryList(): TagQuery[] { + return [this.filter] + } } diff --git a/mediarepo-ui/src/app/models/TagQuery.ts b/mediarepo-ui/src/app/models/TagQuery.ts index f10e907..493f5c7 100644 --- a/mediarepo-ui/src/app/models/TagQuery.ts +++ b/mediarepo-ui/src/app/models/TagQuery.ts @@ -1,7 +1,17 @@ +import {SingleFilterExpression} from "./FilterExpression"; + export class TagQuery { constructor(public tag: string, public negate: boolean) { } + public static fromString(tag: string): TagQuery { + if (tag.startsWith("-")) { + return new TagQuery(tag.replace(/^-/g, ''), true); + } else { + return new TagQuery(tag, false); + } + } + public getNormalizedTag(): string { return this.negate ? "-" + this.tag : this.tag; }