diff --git a/mediarepo-ui/src/app/app.component-theme.scss b/mediarepo-ui/src/app/app.component-theme.scss index 3085e01..79ac3d7 100644 --- a/mediarepo-ui/src/app/app.component-theme.scss +++ b/mediarepo-ui/src/app/app.component-theme.scss @@ -11,7 +11,8 @@ color: white } ::ng-deep ::-webkit-scrollbar { - width: 10px; + width: 15px; + height: 15px; } ::ng-deep ::-webkit-scrollbar-thumb { diff --git a/mediarepo-ui/src/app/app.module.ts b/mediarepo-ui/src/app/app.module.ts index 042f63c..f048369 100644 --- a/mediarepo-ui/src/app/app.module.ts +++ b/mediarepo-ui/src/app/app.module.ts @@ -35,6 +35,8 @@ import {MatRippleModule} from "@angular/material/core"; import {FilterDialogComponent} from './components/file-search/filter-dialog/filter-dialog.component'; import {MatDialogModule} from "@angular/material/dialog"; import {MatSelectModule} from "@angular/material/select"; +import { FileGalleryComponent } from './components/file-gallery/file-gallery.component'; +import { FileGalleryEntryComponent } from './components/file-gallery/file-gallery-entry/file-gallery-entry.component'; @NgModule({ declarations: [ @@ -48,6 +50,8 @@ import {MatSelectModule} from "@angular/material/select"; FileSearchComponent, SearchPageComponent, FilterDialogComponent, + FileGalleryComponent, + FileGalleryEntryComponent, ], imports: [ BrowserModule, diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.html b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.html new file mode 100644 index 0000000..1d28185 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.scss b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.scss new file mode 100644 index 0000000..8c1208b --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.scss @@ -0,0 +1,21 @@ +img { + max-height: 100%; + width: auto; + margin: auto; +} + +.image-wrapper { + width: calc(100% - 20px); + height: calc(100% - 20px); + align-items: center; + text-align: center; + background-color: darken(dimgrey, 15); + padding: 10px; + border-radius: 5px; + cursor: pointer; + user-select:none; +} + +.image-wrapper.selected { + background-color: darken(dimgrey, 5); +} diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.spec.ts b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.spec.ts new file mode 100644 index 0000000..5d0579b --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileGalleryEntryComponent } from './file-gallery-entry.component'; + +describe('FileGalleryEntryComponent', () => { + let component: FileGalleryEntryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FileGalleryEntryComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileGalleryEntryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.ts b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.ts new file mode 100644 index 0000000..a666b1e --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery-entry/file-gallery-entry.component.ts @@ -0,0 +1,57 @@ +import { + Component, + EventEmitter, + Input, OnChanges, + OnDestroy, + OnInit, + Output, SimpleChanges +} from '@angular/core'; +import {File} from "../../../models/File"; +import {FileService} from "../../../services/file/file.service"; +import {SafeResourceUrl} from "@angular/platform-browser"; +import {ErrorBrokerService} from "../../../services/error-broker/error-broker.service"; +import {Selectable} from "../../../models/Selectable"; + +@Component({ + selector: 'app-file-gallery-entry', + templateUrl: './file-gallery-entry.component.html', + styleUrls: ['./file-gallery-entry.component.scss'] +}) +export class FileGalleryEntryComponent implements OnInit, OnChanges { + + @Input() file!: Selectable; + @Output() fileSelectEvent = new EventEmitter>(); + contentUrl: SafeResourceUrl | undefined; + + private cachedFile: File | undefined; + + constructor(private fileService: FileService, private errorBroker: ErrorBrokerService) { } + + async ngOnChanges(changes: SimpleChanges): Promise { + if (!this.cachedFile || this.file.data.hash !== this.cachedFile.hash) { // handle changes to the file when the component is not destroyed + this.cachedFile = this.file.data; + await this.loadImage(); + } + } + + async ngOnInit() { + this.cachedFile = this.file.data; + await this.loadImage(); + } + + async loadImage() { + try { + const thumbnails = await this.fileService.getThumbnails(this.file.data.hash); + let thumbnail = thumbnails.find(t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500)); + thumbnail = thumbnail ?? thumbnails[0]; + + if (!thumbnail) { + console.log("Thumbnail is empty?!", thumbnails); + } else { + this.contentUrl = await this.fileService.readThumbnail(thumbnail!!); + } + } catch (err) { + this.errorBroker.showError(err); + } + } +} diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.html b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.html new file mode 100644 index 0000000..e83642b --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.html @@ -0,0 +1,17 @@ + diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.scss b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.scss new file mode 100644 index 0000000..77de222 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.scss @@ -0,0 +1,52 @@ +.file-scroll-viewport { + width: 100%; + height: 100%; +} + +::ng-deep .file-scroll-viewport > .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: row; + height: 100%; +} + +.gallery-container { + height: 100%; + width: 100%; + position: relative; +} + +app-file-gallery-entry, .file-item { + width: 250px; + height: calc(100% - 10px); + padding: 5px; +} + +app-file-gallery-entry { + display: block; +} + +.file-full-view { + width: 100%; + height: 100%; +} + +.file-full-view-inner { + height: 100%; + width: 100%; + display: block; +} + +img { + max-height: 100%; + width: auto; + display: block; + margin: auto; +} + +.close-button { + position: absolute; + top: 0; + right: 0; + width: 3em; + height: 3em; +} diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.spec.ts b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.spec.ts new file mode 100644 index 0000000..7b8f6e3 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileGalleryComponent } from './file-gallery.component'; + +describe('FileGalleryComponent', () => { + let component: FileGalleryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FileGalleryComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileGalleryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.ts b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.ts new file mode 100644 index 0000000..5a433c4 --- /dev/null +++ b/mediarepo-ui/src/app/components/file-gallery/file-gallery.component.ts @@ -0,0 +1,130 @@ +import { + Component, ElementRef, + EventEmitter, HostListener, + Input, + OnChanges, + OnInit, + Output, SimpleChanges, ViewChild +} from '@angular/core'; +import {File} from "../../models/File"; +import {FileService} from "../../services/file/file.service"; +import {SafeResourceUrl} from "@angular/platform-browser"; +import {Selectable} from "../../models/Selectable"; +import {CdkVirtualScrollViewport} from "@angular/cdk/scrolling"; + +@Component({ + selector: 'app-file-gallery', + templateUrl: './file-gallery.component.html', + styleUrls: ['./file-gallery.component.scss'] +}) +export class FileGalleryComponent implements OnChanges, OnInit { + + @Input() files: File[] = []; + @Input() preselectedFile: File | undefined; + @Output() fileSelectEvent = new EventEmitter(); + @Output() fileDblClickEvent = new EventEmitter(); + @Output() closeEvent = new EventEmitter(); + entries: Selectable[] = []; + + @ViewChild("virtualScroll") virtualScroll!: CdkVirtualScrollViewport; + + selectedFile: Selectable | undefined; + fileContentUrl: SafeResourceUrl | undefined; + + constructor(private fileService: FileService) { + } + + /** + * Called when a new entry is selected + * @param {Selectable} entry + * @returns {Promise} + */ + async onEntrySelect(entry: Selectable) { + if (entry) { + this.selectedFile?.unselect(); + entry.select(); + this.selectedFile = entry; + await this.loadSelectedFile(); + this.virtualScroll.scrollToIndex(this.entries.indexOf(entry), "smooth"); + this.fileSelectEvent.emit(this.selectedFile.data); + } + } + + /** + * Loads the content url of the selected file + * @returns {Promise} + */ + async loadSelectedFile() { + if (this.selectedFile) { + this.fileContentUrl = await this.fileService.readFile(this.selectedFile.data); + } + } + + async ngOnInit(): Promise { + if (!this.selectedFile || this.files.indexOf(this.selectedFile.data) < 0) { + await this.onEntrySelect(this.getPreselectedEntry() ?? this.entries[0]) + } + } + + public async ngOnChanges(changes: SimpleChanges): Promise { + this.entries = this.files.map(f => new Selectable(f, f == this.selectedFile?.data)); + + if (!this.selectedFile || this.files.indexOf(this.selectedFile.data) < 0) { + await this.onEntrySelect(this.getPreselectedEntry() ?? this.entries[0]) + } + } + + /** + * Selects the previous item in the gallery + * @returns {Promise} + */ + public async nextItem() { + if (this.selectedFile) { + let index = this.entries.indexOf(this.selectedFile) + 1; + if (index == this.entries.length) { + index--; // restrict to elements + } + await this.onEntrySelect(this.entries[index]); + } else { + await this.onEntrySelect(this.entries[0]) + } + } + + /** + * Selects the next item in the gallery + * @returns {Promise} + */ + public async previousItem() { + if (this.selectedFile) { + let index = this.entries.indexOf(this.selectedFile) - 1; + if (index < 0) { + index++; // restrict to elements + } + await this.onEntrySelect(this.entries[index]); + } else { + await this.onEntrySelect(this.entries[0]) + } + } + + @HostListener("window:keydown", ["$event"]) + private async handleKeydownEvent(event: KeyboardEvent) { + switch (event.key) { + case "ArrowRight": + await this.nextItem(); + break; + case "ArrowLeft": + await this.previousItem(); + break; + } + } + + private getPreselectedEntry(): Selectable | undefined { + if (this.preselectedFile) { + const entry = this.entries.find(e => e.data.hash == this.preselectedFile?.hash); + if (entry) { + return entry; + } + } + return undefined; + } +} diff --git a/mediarepo-ui/src/app/components/file-grid/file-grid-entry/file-grid-entry.component.ts b/mediarepo-ui/src/app/components/file-grid/file-grid-entry/file-grid-entry.component.ts index 76c568c..7dc5b28 100644 --- a/mediarepo-ui/src/app/components/file-grid/file-grid-entry/file-grid-entry.component.ts +++ b/mediarepo-ui/src/app/components/file-grid/file-grid-entry/file-grid-entry.component.ts @@ -3,7 +3,7 @@ import { Input, OnInit, ViewChild, - ElementRef, Output, EventEmitter, OnDestroy + ElementRef, Output, EventEmitter, OnDestroy, OnChanges } from '@angular/core'; import {File} from "../../../models/File"; import {FileService} from "../../../services/file/file.service"; @@ -18,26 +18,27 @@ import {GridEntry} from "./GridEntry"; templateUrl: './file-grid-entry.component.html', styleUrls: ['./file-grid-entry.component.scss'] }) -export class FileGridEntryComponent implements OnInit, OnDestroy { +export class FileGridEntryComponent implements OnInit, OnChanges { @ViewChild("card") card!: ElementRef; @Input() public gridEntry!: GridEntry; @Output() clickEvent = new EventEmitter(); @Output() dblClickEvent = new EventEmitter(); - selectedThumbnail: Thumbnail | undefined; contentUrl: SafeResourceUrl | undefined; + private cachedFile: File | undefined; constructor(private fileService: FileService, private errorBroker: ErrorBrokerService) { } async ngOnInit() { + this.cachedFile = this.gridEntry.file; await this.loadImage(); } - public ngOnDestroy(): void { - if (this.contentUrl) { - const url = this.contentUrl; - this.contentUrl = undefined; + async ngOnChanges() { + if (!this.cachedFile || this.gridEntry.file.hash !== this.cachedFile.hash) { + this.cachedFile = this.gridEntry.file; + await this.loadImage(); } } @@ -45,7 +46,7 @@ export class FileGridEntryComponent implements OnInit, OnDestroy { try { const thumbnails = await this.fileService.getThumbnails(this.gridEntry.file.hash); let thumbnail = thumbnails.find(t => (t.height > 250 || t.width > 250) && (t.height < 500 && t.width < 500)); - this.selectedThumbnail = thumbnail ?? thumbnails[0]; + thumbnail = thumbnail ?? thumbnails[0]; if (!thumbnail) { console.log("Thumbnail is empty?!", thumbnails); diff --git a/mediarepo-ui/src/app/models/Selectable.ts b/mediarepo-ui/src/app/models/Selectable.ts new file mode 100644 index 0000000..f522b18 --- /dev/null +++ b/mediarepo-ui/src/app/models/Selectable.ts @@ -0,0 +1,16 @@ +export class Selectable { + constructor(public data: T, public selected: boolean) { + } + + public select() { + this.selected = true; + } + + public unselect() { + this.selected = false; + } + + public toggle() { + this.selected = !this.selected; + } +} diff --git a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.html b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.html index 3359c08..f24ef21 100644 --- a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.html +++ b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.html @@ -16,9 +16,13 @@ - + + diff --git a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.scss b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.scss index 179cb35..1e0f5c4 100644 --- a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.scss +++ b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.scss @@ -43,6 +43,12 @@ app-file-grid { padding: 0; } +app-file-gallery { + padding: 0; + height: 100%; + width: 100%; +} + .page { height: 100%; width: 100%; diff --git a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.ts b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.ts index 93f2988..4c746e8 100644 --- a/mediarepo-ui/src/app/pages/home/search-page/search-page.component.ts +++ b/mediarepo-ui/src/app/pages/home/search-page/search-page.component.ts @@ -19,6 +19,8 @@ export class SearchPageComponent implements OnInit { tags: Tag[] = []; files: File[] = []; private openingLightbox = false; + showGallery = false; + preselectedFile: File | undefined; @ViewChild('filesearch') fileSearch!: FileSearchComponent; @@ -94,6 +96,11 @@ export class SearchPageComponent implements OnInit { this.openingLightbox = false; } + async openGallery(preselectedFile: File) { + this.preselectedFile = preselectedFile; + this.showGallery = true; + } + private async openLightbox(file: File): Promise { let url = await this.fileService.readFile(file);