diff --git a/mediarepo-ui/src/app/app.module.ts b/mediarepo-ui/src/app/app.module.ts
index ffaf063..dceb63e 100644
--- a/mediarepo-ui/src/app/app.module.ts
+++ b/mediarepo-ui/src/app/app.module.ts
@@ -54,6 +54,10 @@ 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';
+import { TagInputComponent } from './components/inputs/tag-input/tag-input.component';
+import { ContextMenuComponent } from './components/context-menu/context-menu.component';
@NgModule({
declarations: [
@@ -78,6 +82,10 @@ import {MatCheckboxModule} from "@angular/material/checkbox";
ImportTabSidebarComponent,
NativeFileSelectComponent,
FilesystemImportComponent,
+ FilterDialogComponent,
+ TagFilterListItemComponent,
+ TagInputComponent,
+ ContextMenuComponent,
],
imports: [
BrowserModule,
diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.html b/mediarepo-ui/src/app/components/context-menu/context-menu.component.html
new file mode 100644
index 0000000..d182a4b
--- /dev/null
+++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss b/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss
new file mode 100644
index 0000000..960a1df
--- /dev/null
+++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.scss
@@ -0,0 +1,4 @@
+.menu-anchor {
+ visibility: hidden;
+ position: fixed;
+}
diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts b/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts
new file mode 100644
index 0000000..55a3414
--- /dev/null
+++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ContextMenuComponent } from './context-menu.component';
+
+describe('ContextMenuComponent', () => {
+ let component: ContextMenuComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ ContextMenuComponent ]
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ContextMenuComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts b/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts
new file mode 100644
index 0000000..38e0105
--- /dev/null
+++ b/mediarepo-ui/src/app/components/context-menu/context-menu.component.ts
@@ -0,0 +1,32 @@
+import {
+ Component,
+ ComponentFactoryResolver,
+ OnInit,
+ ViewChild,
+ ViewContainerRef
+} from '@angular/core';
+import {MatMenuTrigger} from "@angular/material/menu";
+
+@Component({
+ selector: 'app-context-menu',
+ templateUrl: './context-menu.component.html',
+ styleUrls: ['./context-menu.component.scss']
+})
+export class ContextMenuComponent {
+
+
+ public x: string = "0";
+ public y: string = "0";
+
+ @ViewChild(MatMenuTrigger) menuTrigger!: MatMenuTrigger
+
+ constructor() {
+ }
+
+ public onContextMenu(event: MouseEvent) {
+ event.preventDefault();
+ this.x = event.clientX + "px";
+ this.y = event.clientY + "px";
+ this.menuTrigger.openMenu();
+ }
+}
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..1dfe215 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: -13px;
+ 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..da3f060 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,8 @@ import {
FilterExpression,
SingleFilterExpression
} from "../../models/FilterExpression";
+import {FilterDialogComponent} from "./filter-dialog/filter-dialog.component";
+import {Tag} from "../../models/Tag";
@Component({
@@ -37,6 +39,7 @@ export class FileSearchComponent implements AfterViewChecked, OnInit {
public filters: FilterExpression[] = [];
public suggestionTags: Observable;
+ @Input() availableTags: Tag[] = [];
@Input() validTags: string[] = [];
@Output() searchStartEvent = new EventEmitter();
@Output() searchEndEvent = new EventEmitter();
@@ -70,12 +73,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 +143,23 @@ 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",
+ height: "80vh",
+ data: {
+ filterEntries,
+ availableTags: this.availableTags,
+ },
+ 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..3d76050
--- /dev/null
+++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.html
@@ -0,0 +1,23 @@
+Filters
+
+
+
+
+
+
+
+
+
+
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..ca664f3
--- /dev/null
+++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.scss
@@ -0,0 +1,41 @@
+.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;
+ user-select: none;
+ cursor: pointer;
+}
+
+app-tag-filter-list-item {
+ width: 100%;
+}
+
+.selected {
+ background-color: #5c5c5c;
+}
+
+.filter-dialog-content {
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+ margin: 0;
+}
+
+.filter-dialog-list {
+ overflow-y: auto;
+ height: 100%;
+ 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..ce97c95
--- /dev/null
+++ b/mediarepo-ui/src/app/components/file-search/filter-dialog/filter-dialog.component.ts
@@ -0,0 +1,167 @@
+import {Component, HostListener, Inject, ViewChildren} 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 {TagQuery} from "../../../models/TagQuery";
+import {Tag} from "../../../models/Tag";
+import {TagFilterListItemComponent} from "./tag-filter-list-item/tag-filter-list-item.component";
+import {Selectable} from "../../../models/Selectable";
+
+@Component({
+ selector: 'app-filter-dialog',
+ templateUrl: './filter-dialog.component.html',
+ styleUrls: ['./filter-dialog.component.scss']
+})
+export class FilterDialogComponent {
+
+ public filters: Selectable[];
+ public availableTags: Tag[] = [];
+ public mode: "AND" | "OR" = "AND";
+
+ @ViewChildren(
+ TagFilterListItemComponent) filterListItems!: TagFilterListItemComponent[];
+
+ private selectedQueries: TagQuery[] = [];
+
+ constructor(public dialogRef: MatDialogRef, @Inject(
+ MAT_DIALOG_DATA) data: any) {
+ this.filters = data.filterEntries.map(
+ (f: FilterExpression) => new Selectable(f,
+ false)) ?? [];
+ this.availableTags = data.availableTags ?? [];
+ }
+
+ public cancelFilter(): void {
+ this.dialogRef.close();
+ }
+
+ public confirmFilter(): void {
+ this.dialogRef.close(this.filters.map(f => f.data));
+ }
+
+ public removeFilter(event: TagFilterListItemComponent): void {
+ const filter = event.expression;
+ const index = this.filters.findIndex(f => f === filter);
+ if (index >= 0) {
+ this.filters.splice(index, 1);
+ }
+ this.unselectAll();
+ }
+
+ public addFilter(tag: string) {
+ const query = TagQuery.fromString(tag);
+
+ if (this.mode === "AND" || this.filters.length === 0) {
+ this.filters.push(
+ new Selectable(new SingleFilterExpression(query),
+ false));
+ tag = tag.replace(/^-/g, '');
+
+ if (this.filters.filter(t => t.data.partiallyEq(tag)).length > 1) {
+ const index = this.filters.findIndex(t => t.data.partiallyEq(tag));
+ this.filters.splice(index, 1);
+ }
+ } else {
+ let queryList = this.filters.pop()?.data.queryList() ?? [];
+
+ queryList.push(query);
+ const filterExpression = new OrFilterExpression(queryList);
+ filterExpression.removeDuplicates();
+ this.filters.push(
+ new Selectable(filterExpression,
+ false));
+ }
+ this.unselectAll();
+ }
+
+ public addToSelection(query: TagQuery): void {
+ this.selectedQueries.push(query);
+ }
+
+ public removeFromSelection(query: TagQuery): void {
+ const index = this.selectedQueries.indexOf(query);
+ if (index > 0) {
+ this.selectedQueries.splice(index, 1);
+ }
+ }
+
+ public unselectAll() {
+ this.filters.forEach(filter => filter.selected = false);
+ this.selectedQueries = [];
+ this.filterListItems.forEach(i => i.selectedIndices = []);
+ }
+
+ public convertSelectionToAndExpression(): void {
+ for (const query of this.selectedQueries) {
+ this.filters.push(new Selectable(new SingleFilterExpression(query), false));
+ }
+ this.removeFilterDuplicates();
+ this.unselectAll();
+ }
+
+ public convertSelectionToOrExpression(): void {
+ const queries = this.selectedQueries;
+ const expression = new OrFilterExpression(queries);
+ this.filters.push(new Selectable(expression, false));
+ this.removeFilterDuplicates();
+ this.unselectAll();
+ }
+
+ public invertSelection(): void {
+ this.selectedQueries.forEach(query => query.negate = !query.negate);
+ }
+
+ private removeFilterDuplicates() {
+ const filters = this.filters;
+ let newFilters: Selectable[] = [];
+
+ for (const filterItem of filters) {
+ if (filterItem.data.filter_type == "OrExpression") {
+ (filterItem.data as OrFilterExpression).removeDuplicates();
+ }
+ if (newFilters.findIndex(f => FilterDialogComponent.checkFiltersEqual(f.data, filterItem.data)) < 0) {
+ if (filterItem.data.filter_type == "OrExpression" && filterItem.data.queryList().length === 1) {
+ filterItem.data = new SingleFilterExpression(filterItem.data.queryList()[0]);
+ }
+ newFilters.push(filterItem);
+ }
+ }
+ this.filters = newFilters;
+ }
+
+ private static checkFiltersEqual(l: FilterExpression, r: FilterExpression): boolean {
+ const lTags = l.queryList().map(q => q.getNormalizedTag()).sort();
+ const rTags = r.queryList().map(q => q.getNormalizedTag()).sort();
+ let match = false;
+
+ if (lTags.length == rTags.length) {
+ match = true;
+
+ for (const tag of lTags) {
+ match = rTags.includes(tag);
+ if (!match) {
+ break;
+ }
+ }
+ }
+ return match;
+ }
+
+ @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..b1116e3
--- /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,18 @@
+
+ {{expression.data.getDisplayName()}}
+
+
+
+
+
+ 0">OR
+ {{entry[1].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..37a6050
--- /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,41 @@
+.remove-button, .remove-button-inner-list {
+ position: absolute;
+ right: 0;
+ z-index: 999;
+ top: calc(0.5em - 15px);
+}
+
+.remove-button {
+ right: 16px;
+}
+
+mat-list {
+ height: 100%;
+ width: 100%;
+ display: block;
+ background-color: #353535;
+ padding: 0;
+ border-radius: 0.25em;
+}
+
+mat-list-item.or-filter-list-item {
+ padding: 0.5em 0;
+ height: 100%;
+ width: 100%;
+ border-collapse: collapse;
+ cursor: pointer;
+ user-select: none;
+
+ ::ng-deep .mat-list-item-content {
+ padding-right: 0;
+ margin-right: 0;
+ }
+}
+
+.or-span {
+ margin-right: 0.5em;
+}
+
+.selected {
+ background-color: #5c5c5c;
+}
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..f2d0b03
--- /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,75 @@
+import {
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Inject,
+ Input, OnChanges,
+ OnInit,
+ Output, SimpleChanges
+} from '@angular/core';
+import {
+ FilterExpression,
+ OrFilterExpression, SingleFilterExpression
+} from "../../../../models/FilterExpression";
+import {TagQuery} from "../../../../models/TagQuery";
+import {Selectable} from "../../../../models/Selectable";
+
+@Component({
+ selector: 'app-tag-filter-list-item',
+ templateUrl: './tag-filter-list-item.component.html',
+ styleUrls: ['./tag-filter-list-item.component.scss']
+})
+export class TagFilterListItemComponent implements OnChanges {
+
+ @Input() expression!: Selectable;
+ @Output() removeClicked = new EventEmitter();
+ @Output() querySelect = new EventEmitter();
+ @Output() queryUnselect = new EventEmitter();
+
+ public selectedIndices: number[] = [];
+
+ constructor(private changeDetector: ChangeDetectorRef) { }
+
+ public ngOnChanges(changes: SimpleChanges): void {
+ if (changes["expression"]) {
+ this.selectedIndices = [];
+ }
+ }
+
+ public enumerate(items: T[]): [number, T][] {
+ return items.map((value, index) => [index, value]);
+ }
+
+ public removeOrExpression(index: number) {
+ const expression = this.expression.data as OrFilterExpression;
+ expression.removeQueryEntry(index);
+
+ if (expression.filter.length == 0) {
+ this.removeClicked.emit(this);
+ } else if (expression.filter.length == 1) {
+ this.expression.data = new SingleFilterExpression(expression.filter[0]);
+ }
+ }
+
+ public selectInnerIndex(index: number): void {
+ const expression = this.expression.data as OrFilterExpression;
+
+ if (this.selectedIndices.includes(index)) {
+ const elementIndex = this.selectedIndices.indexOf(index);
+ this.selectedIndices.splice(elementIndex, 1);
+ this.queryUnselect.emit(expression.filter[index]);
+ } else {
+ this.selectedIndices.push(index);
+ this.querySelect.emit(expression.filter[index]);
+ }
+ }
+
+ public onSelect(): void {
+ this.expression.selected = !this.expression.selected;
+ if (this.expression.selected) {
+ this.querySelect.emit(this.expression.data.filter as TagQuery);
+ } else {
+ this.queryUnselect.emit(this.expression.data.filter as TagQuery);
+ }
+ }
+}
diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html
new file mode 100644
index 0000000..b1b8997
--- /dev/null
+++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.html
@@ -0,0 +1,15 @@
+
+
+ Enter a tag
+
+
+
+
+ {{tag}}
+
+
+
diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss
new file mode 100644
index 0000000..c7acb4b
--- /dev/null
+++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.scss
@@ -0,0 +1,3 @@
+mat-form-field {
+ width: 100%;
+}
diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts
new file mode 100644
index 0000000..b94bb54
--- /dev/null
+++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.spec.ts
@@ -0,0 +1,25 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TagInputComponent } from './tag-input.component';
+
+describe('TagInputComponent', () => {
+ let component: TagInputComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [ TagInputComponent ]
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TagInputComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts
new file mode 100644
index 0000000..178f138
--- /dev/null
+++ b/mediarepo-ui/src/app/components/inputs/tag-input/tag-input.component.ts
@@ -0,0 +1,114 @@
+import {
+ Component, ElementRef,
+ EventEmitter,
+ Input, OnChanges,
+ OnInit,
+ Output, SimpleChanges,
+ ViewChild
+} from '@angular/core';
+import {Tag} from "../../../models/Tag";
+import {FormControl} from "@angular/forms";
+import {MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
+import {Observable} from "rxjs";
+import {debounceTime, delay, map, startWith} from "rxjs/operators";
+
+@Component({
+ selector: 'app-tag-input',
+ templateUrl: './tag-input.component.html',
+ styleUrls: ['./tag-input.component.scss']
+})
+export class TagInputComponent implements OnChanges{
+
+ @Input() availableTags: Tag[] = [];
+ @Input() allowNegation: boolean = false;
+ @Input() allowInvalid: boolean = false;
+ @Output() tagAdded = new EventEmitter();
+
+ @ViewChild("tagInput") tagInput!: ElementRef;
+ public formControl = new FormControl();
+ public autosuggestTags: Observable;
+ private tagsForAutocomplete: string[] = [];
+
+ constructor() {
+ this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput());
+ this.autosuggestTags = this.formControl.valueChanges.pipe(
+ startWith(null),
+ debounceTime(250),
+ map((tag: string | null) => tag ? this.filterSuggestionTag(tag) : this.tagsForAutocomplete.slice(0, 20)));
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes["availableTags"]) {
+ this.tagsForAutocomplete = this.availableTags.map(t => t.getNormalizedOutput());
+ }
+ }
+
+ public addTagByInput(event: any): void {
+ this.addTag(this.formControl.value);
+ }
+
+ public addTagByAutocomplete(event: MatAutocompleteSelectedEvent): void {
+ this.addTag(event.option.value);
+ }
+
+ private addTag(value: string) {
+ const tag = this.normalizeTag(value);
+ if (tag.length > 0 && (this.allowInvalid || this.checkTagValid(tag))) {
+ this.tagAdded.emit(tag);
+ this.formControl.setValue("");
+ this.tagInput.nativeElement.value = "";
+ }
+ }
+
+ private filterSuggestionTag(tag: string) {
+ let normalizedTag = this.normalizeTag(tag);
+ const negated = normalizedTag.startsWith("-") && this.allowNegation;
+ normalizedTag = this.allowNegation? normalizedTag.replace(/^-/, "") : normalizedTag;
+
+ return this.tagsForAutocomplete.filter(
+ t => t.includes(normalizedTag))
+ .map(t => negated ? "-" + t: t)
+ .sort((l, r) => this.compareSuggestionTags(normalizedTag, l, r))
+ .slice(0, 20);
+ }
+
+ private checkTagValid(tag: string): boolean {
+ if (this.allowNegation) {
+ tag = tag.replace(/^-/, "");
+ }
+ return this.tagsForAutocomplete.includes(tag);
+ }
+
+ /**
+ * Normalizes the tag by removing whitespaces
+ * @param {string} tag
+ * @returns {string}
+ * @private
+ */
+ private normalizeTag(tag: string): string {
+ let normalizedTag = tag.trim();
+ let parts = normalizedTag.split(":");
+
+ if (parts.length > 1) {
+ const namespace = parts.shift()!.trim();
+ const name = parts.join(":").trim();
+ return namespace + ":" + name;
+ } else {
+ return normalizedTag;
+ }
+ }
+
+ private compareSuggestionTags(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)
+ }
+ }
+}
diff --git a/mediarepo-ui/src/app/models/FilterExpression.ts b/mediarepo-ui/src/app/models/FilterExpression.ts
index 33ac090..e2e8d62 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,31 @@ 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;
+ }
+
+ public removeQueryEntry(index: number) {
+ this.filter.splice(index, 1);
+ }
+
+ public removeDuplicates() {
+ const filters = this.filter.reverse();
+ let newEntries: TagQuery[] = [];
+
+ for (const entry of filters) {
+ if (newEntries.findIndex(f => f.tag === entry.tag) < 0) {
+ newEntries.push(entry);
+ }
+ }
+ this.filter = newEntries.reverse();
+ }
}
export class SingleFilterExpression implements FilterExpression {
@@ -51,4 +80,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;
}
diff --git a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html
index e837b8f..83d9da1 100644
--- a/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html
+++ b/mediarepo-ui/src/app/pages/home/files-tab/files-tab-sidebar/files-tab-sidebar.component.html
@@ -5,7 +5,8 @@