From a6b14534eea45dfdad3d2815baf74b7fd989aa4c Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Wed, 16 Feb 2022 22:17:38 +0100 Subject: [PATCH] Implementing filter #287 --- src/frontend/app/app.module.ts | 6 +- src/frontend/app/model/query.service.ts | 4 +- ...{gallery.service.ts => content.service.ts} | 2 +- .../filter/filter.gallery.component.css | 23 +++ .../filter/filter.gallery.component.html | 29 +++ .../filter/filter.gallery.component.ts | 29 +++ .../app/ui/gallery/filter/filter.service.ts | 173 ++++++++++++++++++ .../app/ui/gallery/gallery.component.html | 12 +- .../app/ui/gallery/gallery.component.ts | 39 ++-- .../ui/gallery/grid/grid.gallery.component.ts | 20 +- .../lightbox/lightbox.gallery.component.ts | 4 +- .../navigator/navigator.gallery.component.css | 10 +- .../navigator.gallery.component.html | 15 +- .../navigator/navigator.gallery.component.ts | 6 +- .../ui/gallery/navigator/sorting.service.ts | 15 +- .../random-query-builder.gallery.component.ts | 4 +- .../search/search.gallery.component.ts | 4 +- .../gallery/share/share.gallery.component.ts | 4 +- 18 files changed, 334 insertions(+), 65 deletions(-) rename src/frontend/app/ui/gallery/{gallery.service.ts => content.service.ts} (99%) create mode 100644 src/frontend/app/ui/gallery/filter/filter.gallery.component.css create mode 100644 src/frontend/app/ui/gallery/filter/filter.gallery.component.html create mode 100644 src/frontend/app/ui/gallery/filter/filter.gallery.component.ts create mode 100644 src/frontend/app/ui/gallery/filter/filter.service.ts diff --git a/src/frontend/app/app.module.ts b/src/frontend/app/app.module.ts index b46611f3..1e53acfc 100644 --- a/src/frontend/app/app.module.ts +++ b/src/frontend/app/app.module.ts @@ -3,7 +3,7 @@ import {BrowserModule, HAMMER_GESTURE_CONFIG, HammerGestureConfig, HammerModule} import {FormsModule} from '@angular/forms'; import {AppComponent} from './app.component'; import {UserService} from './model/network/user.service'; -import {GalleryService} from './ui/gallery/gallery.service'; +import {ContentService} from './ui/gallery/content.service'; import {NetworkService} from './model/network/network.service'; import {GalleryCacheService} from './ui/gallery/cache.gallery.service'; import {FullScreenService} from './ui/gallery/fullscreen.service'; @@ -116,6 +116,7 @@ import {PreviewSettingsComponent} from './ui/settings/preview/preview.settings.c import {GallerySearchFieldComponent} from './ui/gallery/search/search-field/search-field.gallery.component'; import {GalleryFilterComponent} from './ui/gallery/filter/filter.gallery.component'; import {GallerySortingService} from './ui/gallery/navigator/sorting.service'; +import {FilterService} from './ui/gallery/filter/filter.service'; @Injectable() export class MyHammerConfig extends HammerGestureConfig { @@ -272,7 +273,8 @@ Marker.prototype.options.icon = iconDefault; UserService, AlbumsService, GalleryCacheService, - GalleryService, + ContentService, + FilterService, GallerySortingService, MapService, BlogService, diff --git a/src/frontend/app/model/query.service.ts b/src/frontend/app/model/query.service.ts index 99dabc83..f07f18fb 100644 --- a/src/frontend/app/model/query.service.ts +++ b/src/frontend/app/model/query.service.ts @@ -3,7 +3,7 @@ import {ShareService} from '../ui/gallery/share.service'; import {MediaDTO} from '../../../common/entities/MediaDTO'; import {QueryParams} from '../../../common/QueryParams'; import {Utils} from '../../../common/Utils'; -import {GalleryService} from '../ui/gallery/gallery.service'; +import {ContentService} from '../ui/gallery/content.service'; import {Config} from '../../../common/config/public/Config'; import {ParentDirectoryDTO, SubDirectoryDTO} from '../../../common/entities/DirectoryDTO'; @@ -12,7 +12,7 @@ export class QueryService { constructor(private shareService: ShareService, - private galleryService: GalleryService) { + private galleryService: ContentService) { } getMediaStringId(media: MediaDTO): string { diff --git a/src/frontend/app/ui/gallery/gallery.service.ts b/src/frontend/app/ui/gallery/content.service.ts similarity index 99% rename from src/frontend/app/ui/gallery/gallery.service.ts rename to src/frontend/app/ui/gallery/content.service.ts index 118b2eb2..80fd78fa 100644 --- a/src/frontend/app/ui/gallery/gallery.service.ts +++ b/src/frontend/app/ui/gallery/content.service.ts @@ -16,7 +16,7 @@ import {FileDTO} from '../../../../common/entities/FileDTO'; @Injectable() -export class GalleryService { +export class ContentService { public content: BehaviorSubject; public directoryContent: Observable; diff --git a/src/frontend/app/ui/gallery/filter/filter.gallery.component.css b/src/frontend/app/ui/gallery/filter/filter.gallery.component.css new file mode 100644 index 00000000..9ec9d83b --- /dev/null +++ b/src/frontend/app/ui/gallery/filter/filter.gallery.component.css @@ -0,0 +1,23 @@ +.filter-column { + max-height: 12em; + overflow-y: auto; +} + +.filter-option { + cursor: pointer; +} + +.filter-container { + margin-top: -1rem; +} + +.filter-container > card-body { + margin: 0; + padding-left: 0; + padding-right: 0; +} + +.unselected { + color: #6c757d; + background-color: #fff; +} diff --git a/src/frontend/app/ui/gallery/filter/filter.gallery.component.html b/src/frontend/app/ui/gallery/filter/filter.gallery.component.html new file mode 100644 index 00000000..3c94df71 --- /dev/null +++ b/src/frontend/app/ui/gallery/filter/filter.gallery.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+
    +
  • + {{option.name === undefined ? unknownText : option.name}} + {{option.count}} + +
  • +
+
Nothing to filter
+
+
+
+
diff --git a/src/frontend/app/ui/gallery/filter/filter.gallery.component.ts b/src/frontend/app/ui/gallery/filter/filter.gallery.component.ts new file mode 100644 index 00000000..68d7e343 --- /dev/null +++ b/src/frontend/app/ui/gallery/filter/filter.gallery.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import {RouterLink} from '@angular/router'; +import {FilterService} from './filter.service'; +import {OnDestroy, OnInit} from '../../../../../../node_modules/@angular/core'; + +@Component({ + selector: 'app-gallery-filter', + styleUrls: ['./filter.gallery.component.css'], + templateUrl: './filter.gallery.component.html', + providers: [RouterLink], +}) +export class GalleryFilterComponent implements OnInit, OnDestroy { + public readonly unknownText; + + constructor(public filterService: FilterService) { + this.unknownText = '<' + $localize`unknown` + '>'; + } + + ngOnDestroy(): void { + setTimeout(() => this.filterService.setShowingFilters(false)); + } + + ngOnInit(): void { + + this.filterService.setShowingFilters(true); + } + +} + diff --git a/src/frontend/app/ui/gallery/filter/filter.service.ts b/src/frontend/app/ui/gallery/filter/filter.service.ts new file mode 100644 index 00000000..18586588 --- /dev/null +++ b/src/frontend/app/ui/gallery/filter/filter.service.ts @@ -0,0 +1,173 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; +import {DirectoryContent} from '../content.service'; +import {map, mergeMap} from 'rxjs/operators'; + +export enum FilterRenderType { + enum = 1, range = 2 +} + +interface Filter { + name: string; + mapFn: (m: PhotoDTO) => (string | number)[] | (string | number); + renderType: FilterRenderType; + isArrayValue?: boolean; +} + +interface SelectedFilter { + filter: Filter; + options: { name: string, count: number, selected: boolean }[]; +} + +@Injectable() +export class FilterService { + public readonly AVAILABLE_FILTERS: Filter[] = [ + { + name: $localize`Keywords`, + mapFn: (m: PhotoDTO): string[] => m.metadata.keywords, + renderType: FilterRenderType.enum, + isArrayValue: true, + }, + { + name: $localize`Faces`, + mapFn: (m: PhotoDTO): string[] => m.metadata.faces?.map(f => f.name), + renderType: FilterRenderType.enum, + isArrayValue: true, + }, + /* { + name: $localize`Date`, + mapFn: (m: PhotoDTO): number => m.metadata.creationDate, + renderType: FilterRenderType.date + },*/ + + + { + name: $localize`Rating`, + mapFn: (m: PhotoDTO): number => m.metadata.rating, + renderType: FilterRenderType.enum + }, + { + name: $localize`Camera`, + mapFn: (m: PhotoDTO): string => m.metadata.cameraData?.model, + renderType: FilterRenderType.enum + }, + { + name: $localize`City`, + mapFn: (m: PhotoDTO): string => m.metadata.positionData?.city, + renderType: FilterRenderType.enum + }, + { + name: $localize`State`, + mapFn: (m: PhotoDTO): string => m.metadata.positionData?.state, + renderType: FilterRenderType.enum + }, + { + name: $localize`Country`, + mapFn: (m: PhotoDTO): string => m.metadata.positionData?.country, + renderType: FilterRenderType.enum + }, + ]; + + public readonly selectedFilters = new BehaviorSubject([ + { + filter: this.AVAILABLE_FILTERS[0], + options: [] + }, { + filter: this.AVAILABLE_FILTERS[1], + options: [] + }, { + filter: this.AVAILABLE_FILTERS[4], + options: [] + }, { + filter: this.AVAILABLE_FILTERS[2], + options: [] + } + ]); + filtersVisible = false; + + constructor() { + } + + public applyFilters(directoryContent: Observable): Observable { + return directoryContent.pipe(mergeMap((dirContent: DirectoryContent) => { + return this.selectedFilters.pipe(map((filters: SelectedFilter[]) => { + if (!dirContent || !dirContent.media || !this.filtersVisible) { + return dirContent; + } + + // clone, so the original won't get overwritten + const c = { + media: dirContent.media, + directories: dirContent.directories, + metaFile: dirContent.metaFile + }; + for (const f of filters) { + + // get options + const valueMap: { [key: string]: any } = {}; + f.options.forEach(o => { + valueMap[o.name] = o; + o.count = 0; // reset count so unknown option can be removed at the end + }); + + if (f.filter.isArrayValue) { + c.media.forEach(m => { + (f.filter.mapFn(m as PhotoDTO) as string[])?.forEach(v => { + valueMap[v] = valueMap[v] || {name: v, count: 0, selected: true}; + valueMap[v].count++; + }); + }); + } else { + c.media.forEach(m => { + const key = f.filter.mapFn(m as PhotoDTO) as string; + valueMap[key] = valueMap[key] || {name: key, count: 0, selected: true}; + valueMap[key].count++; + }); + } + + f.options = Object.values(valueMap).filter(o => o.count > 0).sort((a, b) => b.count - a.count); + + + // apply filters + f.options.forEach(opt => { + if (opt.selected) { + return; + } + if (f.filter.isArrayValue) { + c.media = c.media.filter(m => { + const mapped = (f.filter.mapFn(m as PhotoDTO) as string[]); + if (!mapped) { + return true; + } + return mapped.indexOf(opt.name) === -1; + }); + } else { + c.media = c.media.filter(m => (f.filter.mapFn(m as PhotoDTO) as string) !== opt.name); + } + + }); + + } + return c; + })); + })); + } + + public onFilterChange(): void { + this.selectedFilters.next(this.selectedFilters.value); + } + + setShowingFilters(value: boolean): void { + if (this.filtersVisible === value) { + return; + } + this.filtersVisible = value; + if (!this.filtersVisible) { + this.selectedFilters.value.forEach(f => f.options = []); + } + this.onFilterChange(); + } +} + + diff --git a/src/frontend/app/ui/gallery/gallery.component.html b/src/frontend/app/ui/gallery/gallery.component.html index 52022906..f217d115 100644 --- a/src/frontend/app/ui/gallery/gallery.component.html +++ b/src/frontend/app/ui/gallery/gallery.component.html @@ -45,14 +45,14 @@ + [directories]="directoryContent.directories || []">
+ *ngIf="config.Client.MetaFile.markdown && directoryContent.metaFile && (directoryContent.metaFile | mdFiles).length>0"> + [mdFiles]="directoryContent.metaFile | mdFiles">
+ [photos]="directoryContent.media | photosOnly" + [gpxFiles]="directoryContent.metaFile | gpxFiles">
- diff --git a/src/frontend/app/ui/gallery/gallery.component.ts b/src/frontend/app/ui/gallery/gallery.component.ts index fa9b26dc..116db73e 100644 --- a/src/frontend/app/ui/gallery/gallery.component.ts +++ b/src/frontend/app/ui/gallery/gallery.component.ts @@ -1,23 +1,20 @@ import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {AuthenticationService} from '../../model/network/authentication.service'; import {ActivatedRoute, Params, Router} from '@angular/router'; -import {ContentWrapperWithError, DirectoryContent, GalleryService} from './gallery.service'; +import {ContentService, ContentWrapperWithError, DirectoryContent} from './content.service'; import {GalleryGridComponent} from './grid/grid.gallery.component'; import {Config} from '../../../../common/config/public/Config'; -import {ParentDirectoryDTO, SubDirectoryDTO} from '../../../../common/entities/DirectoryDTO'; -import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO'; import {ShareService} from './share.service'; import {NavigationService} from '../../model/navigation.service'; import {UserRoles} from '../../../../common/entities/UserDTO'; import {interval, Observable, Subscription} from 'rxjs'; -import {ContentWrapper} from '../../../../common/entities/ConentWrapper'; import {PageHelper} from '../../model/page.helper'; import {PhotoDTO} from '../../../../common/entities/PhotoDTO'; import {QueryParams} from '../../../../common/QueryParams'; -import {map, take} from 'rxjs/operators'; +import {take} from 'rxjs/operators'; import {GallerySortingService} from './navigator/sorting.service'; -import {Media} from './Media'; import {MediaDTO} from '../../../../common/entities/MediaDTO'; +import {FilterService} from './filter/filter.service'; @Component({ selector: 'app-gallery', @@ -34,11 +31,10 @@ export class GalleryComponent implements OnInit, OnDestroy { public blogOpen = false; config = Config; - public directories: SubDirectoryDTO[] = []; public isPhotoWithLocation = false; public countDown: { day: number, hour: number, minute: number, second: number } = null; public readonly mapEnabled: boolean; - public readonly directoryContent: Observable; + public directoryContent: DirectoryContent; public readonly mediaObs: Observable; private $counter: Observable; private subscription: { [key: string]: Subscription } = { @@ -48,16 +44,15 @@ export class GalleryComponent implements OnInit, OnDestroy { sorting: null }; - constructor(public galleryService: GalleryService, + constructor(public galleryService: ContentService, private authService: AuthenticationService, private router: Router, private shareService: ShareService, private route: ActivatedRoute, private navigation: NavigationService, + private filterService: FilterService, private sortingService: GallerySortingService) { this.mapEnabled = Config.Client.Map.enabled; - this.directoryContent = this.sortingService.applySorting(this.galleryService.directoryContent); - this.mediaObs = this.directoryContent.pipe(map(c => c?.media)); PageHelper.showScrollY(); } @@ -112,7 +107,10 @@ export class GalleryComponent implements OnInit, OnDestroy { this.showSearchBar = Config.Client.Search.enabled && this.authService.canSearch(); this.showShare = Config.Client.Sharing.enabled && this.authService.isAuthorized(UserRoles.User); this.showRandomPhotoBuilder = Config.Client.RandomPhoto.enabled && this.authService.isAuthorized(UserRoles.User); - this.subscription.content = this.galleryService.content.subscribe(this.onContentChange); + this.subscription.content = this.sortingService.applySorting( + this.filterService.applyFilters(this.galleryService.directoryContent)).subscribe((dc: DirectoryContent) => { + this.onContentChange(dc); + }); this.subscription.route = this.route.params.subscribe(this.onRoute); if (this.shareService.isSharing()) { @@ -149,16 +147,17 @@ export class GalleryComponent implements OnInit, OnDestroy { this.galleryService.loadDirectory(directoryName); }; - private onContentChange = (content: ContentWrapper): void => { - const tmp = (content.searchResult || content.directory || { - directories: [], - media: [] - }) as ParentDirectoryDTO | SearchResultDTO; - this.directories = tmp.directories; - // this.sortDirectories(); + private onContentChange = (content: DirectoryContent): void => { + if (!content) { + return; + } + this.directoryContent = content; + + // enforce change detection on grid + this.directoryContent.media = this.directoryContent.media?.slice(); this.isPhotoWithLocation = false; - for (const media of tmp.media as PhotoDTO[]) { + for (const media of content.media as PhotoDTO[]) { if (media.metadata && media.metadata.positionData && media.metadata.positionData.GPSData && diff --git a/src/frontend/app/ui/gallery/grid/grid.gallery.component.ts b/src/frontend/app/ui/gallery/grid/grid.gallery.component.ts index 63cc8cc2..fb500537 100644 --- a/src/frontend/app/ui/gallery/grid/grid.gallery.component.ts +++ b/src/frontend/app/ui/gallery/grid/grid.gallery.component.ts @@ -5,6 +5,7 @@ import { ElementRef, HostListener, Input, + OnChanges, OnDestroy, OnInit, QueryList, @@ -18,10 +19,10 @@ import {GalleryPhotoComponent} from './photo/photo.grid.gallery.component'; import {OverlayService} from '../overlay.service'; import {Config} from '../../../../../common/config/public/Config'; import {PageHelper} from '../../../model/page.helper'; -import {Observable, Subscription} from 'rxjs'; +import {Subscription} from 'rxjs'; import {ActivatedRoute, Params, Router} from '@angular/router'; import {QueryService} from '../../../model/query.service'; -import {GalleryService} from '../gallery.service'; +import {ContentService} from '../content.service'; import {MediaDTO, MediaDTOUtils} from '../../../../../common/entities/MediaDTO'; import {QueryParams} from '../../../../../common/QueryParams'; @@ -30,13 +31,12 @@ import {QueryParams} from '../../../../../common/QueryParams'; templateUrl: './grid.gallery.component.html', styleUrls: ['./grid.gallery.component.css'], }) -export class GalleryGridComponent implements OnInit, AfterViewInit, OnDestroy { +export class GalleryGridComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { @ViewChild('gridContainer', {static: false}) gridContainer: ElementRef; @ViewChildren(GalleryPhotoComponent) gridPhotoQL: QueryList; - @Input() mediaObs: Observable; @Input() lightbox: GalleryLightboxComponent; - media: MediaDTO[]; + @Input() media: MediaDTO[]; photosToRender: GridMedia[] = []; containerWidth = 0; screenHeight = 0; @@ -60,11 +60,15 @@ export class GalleryGridComponent implements OnInit, AfterViewInit, OnDestroy { private changeDetector: ChangeDetectorRef, public queryService: QueryService, private router: Router, - public galleryService: GalleryService, + public galleryService: ContentService, private route: ActivatedRoute) { } + ngOnChanges(): void { + this.onChange(); + } + ngOnInit(): void { this.subscriptions.route = this.route.queryParams.subscribe((params: Params): void => { if (params[QueryParams.gallery.photo] && params[QueryParams.gallery.photo] !== '') { @@ -76,10 +80,6 @@ export class GalleryGridComponent implements OnInit, AfterViewInit, OnDestroy { this.renderUpToMedia(params[QueryParams.gallery.photo]); } }); - this.mediaObs.subscribe((m) => { - this.media = m || []; - this.onChange(); - }); } onChange = () => { diff --git a/src/frontend/app/ui/gallery/lightbox/lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/lightbox.gallery.component.ts index fa81176b..607cdcc2 100644 --- a/src/frontend/app/ui/gallery/lightbox/lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/lightbox.gallery.component.ts @@ -11,7 +11,7 @@ import {PageHelper} from '../../../model/page.helper'; import {QueryService} from '../../../model/query.service'; import {MediaDTO} from '../../../../../common/entities/MediaDTO'; import {QueryParams} from '../../../../../common/QueryParams'; -import {GalleryService} from '../gallery.service'; +import {ContentService} from '../content.service'; import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; import {ControlsLightboxComponent} from './controls/controls.lightbox.gallery.component'; import {SupportedFormats} from '../../../../../common/SupportedFormats'; @@ -64,7 +64,7 @@ export class GalleryLightboxComponent implements OnDestroy, OnInit { private builder: AnimationBuilder, private router: Router, private queryService: QueryService, - private galleryService: GalleryService, + private galleryService: ContentService, private route: ActivatedRoute) { } diff --git a/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.css b/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.css index 2cd9df30..3213af20 100644 --- a/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.css +++ b/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.css @@ -12,15 +12,15 @@ .photos-count { display: inline-block; + padding-right: 10px; } -.btn-download { - padding-left: 1px; - padding-right: 1px; +.btn-navbar { } + .divider { - margin: 5px 5px 5px 10px; + margin-left: 5px; border-left: 1px solid #6c757d; display: inline-block; } @@ -33,7 +33,7 @@ ol { min-width: 13rem; } -.dropdown-item{ +.dropdown-item { padding: 0.25rem 0.5rem; } diff --git a/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.html b/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.html index e43cdf0e..4445d41b 100644 --- a/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.html +++ b/src/frontend/app/ui/gallery/navigator/navigator.gallery.component.html @@ -10,7 +10,7 @@ @@ -24,7 +24,7 @@ + class="btn btn-navbar"> @@ -34,12 +34,19 @@ + class="btn btn-navbar">
 
+ + + +