1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2025-01-14 14:43:17 +08:00

Implementing grouping

This commit is contained in:
Patrik J. Braun 2023-08-28 21:26:00 +02:00
parent 0cf0e733b4
commit 0a1fb29c9f
14 changed files with 628 additions and 440 deletions

View File

@ -177,6 +177,7 @@ import {
} from '@ng-icons/ionicons';
import {SortingMethodIconComponent} from './ui/sorting-method-icon/sorting-method-icon.component';
import {SafeHtmlPipe} from './pipes/SafeHTMLPipe';
import {DatePipe} from '@angular/common';
@Injectable()
export class MyHammerConfig extends HammerGestureConfig {
@ -190,7 +191,7 @@ export class MyHammerConfig extends HammerGestureConfig {
export class CustomUrlSerializer implements UrlSerializer {
private defaultUrlSerializer: DefaultUrlSerializer =
new DefaultUrlSerializer();
new DefaultUrlSerializer();
parse(url: string): UrlTree {
// Encode parentheses
@ -201,9 +202,9 @@ export class CustomUrlSerializer implements UrlSerializer {
serialize(tree: UrlTree): string {
return this.defaultUrlSerializer
.serialize(tree)
.replace(/%28/g, '(')
.replace(/%29/g, ')');
.serialize(tree)
.replace(/%28/g, '(')
.replace(/%29/g, ')');
}
}
@ -361,6 +362,7 @@ Marker.prototype.options.icon = MarkerFactory.defIcon;
CookieService,
GPXFilesFilterPipe,
MDFilesFilterPipe,
DatePipe
],
bootstrap: [AppComponent],
})

View File

@ -1,15 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import { MediaDTO, MediaDTOUtils } from '../../../common/entities/MediaDTO';
import { PhotoDTO } from '../../../common/entities/PhotoDTO';
import {MediaGroup} from '../ui/gallery/navigator/sorting.service';
@Pipe({ name: 'photosOnly' })
export class PhotoFilterPipe implements PipeTransform {
transform(media: MediaDTO[]): PhotoDTO[] | null {
if (!media) {
transform(mediaGroups: MediaGroup[]): PhotoDTO[] | null {
if (!mediaGroups) {
return null;
}
return media.filter((m: MediaDTO): boolean =>
MediaDTOUtils.isPhoto(m)
) as PhotoDTO[];
const ret = [];
for(let i = 0; i < mediaGroups.length; ++i){
ret.push(...mediaGroups[i].media.filter((m: MediaDTO): boolean =>
MediaDTOUtils.isPhoto(m)
) as PhotoDTO[])
}
return ret;
}
}

View File

@ -39,19 +39,19 @@ export class FilterService {
{
name: $localize`Faces`,
mapFn: (m: PhotoDTO): string[] =>
m.metadata.faces
? m.metadata.faces.map((f) => f.name)
: ['<' + $localize`no face` + '>'],
m.metadata.faces
? m.metadata.faces.map((f) => f.name)
: ['<' + $localize`no face` + '>'],
renderType: FilterRenderType.enum,
isArrayValue: true,
},
{
name: $localize`Faces groups`,
mapFn: (m: PhotoDTO): string =>
m.metadata.faces
?.map((f) => f.name)
.sort()
.join(', '),
m.metadata.faces
?.map((f) => f.name)
.sort()
.join(', '),
renderType: FilterRenderType.enum,
isArrayValue: false,
},
@ -124,18 +124,18 @@ export class FilterService {
private getStatistic(prefiltered: DirectoryContent): { date: Date, endDate: Date, dateStr: string, count: number, max: number }[] {
if (!prefiltered ||
!prefiltered.media ||
prefiltered.media.length === 0) {
!prefiltered.media ||
prefiltered.media.length === 0) {
return [];
}
const ret: { date: Date, endDate: Date, dateStr: string, count: number, max: number }[] = [];
const minDate = prefiltered.media.reduce(
(p, curr) => Math.min(p, curr.metadata.creationDate),
Number.MAX_VALUE - 1
(p, curr) => Math.min(p, curr.metadata.creationDate),
Number.MAX_VALUE - 1
);
const maxDate = prefiltered.media.reduce(
(p, curr) => Math.max(p, curr.metadata.creationDate),
Number.MIN_VALUE + 1
(p, curr) => Math.max(p, curr.metadata.creationDate),
Number.MIN_VALUE + 1
);
const diff = (maxDate - minDate) / 1000;
const H = 60 * 60;
@ -221,124 +221,124 @@ export class FilterService {
}
public applyFilters(
directoryContent: Observable<DirectoryContent>
directoryContent: Observable<DirectoryContent>
): Observable<DirectoryContent> {
return directoryContent.pipe(
switchMap((dirContent: DirectoryContent) => {
this.statistic = this.getStatistic(dirContent);
this.resetFilters(false);
return this.activeFilters.pipe(
map((afilters) => {
if (!dirContent || !dirContent.media || (!afilters.filtersVisible && !afilters.areFiltersActive)) {
return dirContent;
}
// clone, so the original won't get overwritten
const c = {
media: dirContent.media,
directories: dirContent.directories,
metaFile: dirContent.metaFile,
};
/* Date Selector */
if (c.media.length > 0) {
// Update date filter range
afilters.dateFilter.minDate = c.media.reduce(
(p, curr) => Math.min(p, curr.metadata.creationDate),
Number.MAX_VALUE - 1
);
afilters.dateFilter.maxDate = c.media.reduce(
(p, curr) => Math.max(p, curr.metadata.creationDate),
Number.MIN_VALUE + 1
);
// Add a few sec padding
afilters.dateFilter.minDate -= (afilters.dateFilter.minDate % 1000) + 1000;
afilters.dateFilter.maxDate += (afilters.dateFilter.maxDate % 1000) + 1000;
if (afilters.dateFilter.minFilter === Number.MIN_VALUE) {
afilters.dateFilter.minFilter = afilters.dateFilter.minDate;
}
if (afilters.dateFilter.maxFilter === Number.MAX_VALUE) {
afilters.dateFilter.maxFilter = afilters.dateFilter.maxDate;
}
// Apply Date filter
c.media = c.media.filter(
(m) =>
m.metadata.creationDate >= afilters.dateFilter.minFilter &&
m.metadata.creationDate <= afilters.dateFilter.maxFilter
);
} else {
afilters.dateFilter.minDate = Number.MIN_VALUE;
afilters.dateFilter.maxDate = Number.MAX_VALUE;
afilters.dateFilter.minFilter = Number.MIN_VALUE;
afilters.dateFilter.maxFilter = Number.MAX_VALUE;
}
// filters
for (const f of afilters.selectedFilters) {
/* Update filter 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;
switchMap((dirContent: DirectoryContent) => {
this.statistic = this.getStatistic(dirContent);
this.resetFilters(false);
return this.activeFilters.pipe(
map((afilters) => {
if (!dirContent || !dirContent.media || (!afilters.filtersVisible && !afilters.areFiltersActive)) {
return dirContent;
}
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
// clone, so the original won't get overwritten
const c = {
media: dirContent.media,
directories: dirContent.directories,
metaFile: dirContent.metaFile,
};
/* Date Selector */
if (c.media.length > 0) {
// Update date filter range
afilters.dateFilter.minDate = c.media.reduce(
(p, curr) => Math.min(p, curr.metadata.creationDate),
Number.MAX_VALUE - 1
);
afilters.dateFilter.maxDate = c.media.reduce(
(p, curr) => Math.max(p, curr.metadata.creationDate),
Number.MIN_VALUE + 1
);
// Add a few sec padding
afilters.dateFilter.minDate -= (afilters.dateFilter.minDate % 1000) + 1000;
afilters.dateFilter.maxDate += (afilters.dateFilter.maxDate % 1000) + 1000;
if (afilters.dateFilter.minFilter === Number.MIN_VALUE) {
afilters.dateFilter.minFilter = afilters.dateFilter.minDate;
}
if (afilters.dateFilter.maxFilter === Number.MAX_VALUE) {
afilters.dateFilter.maxFilter = afilters.dateFilter.maxDate;
}
// Apply Date filter
c.media = c.media.filter(
(m) =>
m.metadata.creationDate >= afilters.dateFilter.minFilter &&
m.metadata.creationDate <= afilters.dateFilter.maxFilter
);
} else {
afilters.dateFilter.minDate = Number.MIN_VALUE;
afilters.dateFilter.maxDate = Number.MAX_VALUE;
afilters.dateFilter.minFilter = Number.MIN_VALUE;
afilters.dateFilter.maxFilter = Number.MAX_VALUE;
}
});
}
// If th e number of photos did not change, the filters are not active
afilters.areFiltersActive = c.media.length !== dirContent.media.length;
return c;
})
);
})
// filters
for (const f of afilters.selectedFilters) {
/* Update filter 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
);
}
});
}
// If the number of photos did not change, the filters are not active
afilters.areFiltersActive = c.media.length !== dirContent.media.length;
return c;
})
);
})
);
}

View File

@ -55,10 +55,10 @@
class="rounded"
[class.rounded-start-0]="ShowMarkDown"
*ngIf="ShowMap"
[photos]="directoryContent?.media | photosOnly"
[photos]="directoryContent?.mediaGroups | photosOnly"
[gpxFiles]="directoryContent?.metaFile | gpxFiles"></app-gallery-map>
</div>
<app-gallery-grid [media]="directoryContent?.media"
<app-gallery-grid [mediaGroups]="directoryContent?.mediaGroups"
[lightbox]="lightbox"></app-gallery-grid>
</ng-container>

View File

@ -1,7 +1,7 @@
import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core';
import {AuthenticationService} from '../../model/network/authentication.service';
import {ActivatedRoute, Params, Router} from '@angular/router';
import {ContentService, ContentWrapperWithError, DirectoryContent,} from './content.service';
import {ContentService, ContentWrapperWithError,} from './content.service';
import {GalleryGridComponent} from './grid/grid.gallery.component';
import {Config} from '../../../../common/config/public/Config';
import {ShareService} from './share.service';
@ -12,7 +12,7 @@ import {PageHelper} from '../../model/page.helper';
import {PhotoDTO} from '../../../../common/entities/PhotoDTO';
import {QueryParams} from '../../../../common/QueryParams';
import {take} from 'rxjs/operators';
import {GallerySortingService} from './navigator/sorting.service';
import {GallerySortingService, GroupedDirectoryContent} from './navigator/sorting.service';
import {MediaDTO} from '../../../../common/entities/MediaDTO';
import {FilterService} from './filter/filter.service';
import {PiTitleService} from '../../model/pi-title.service';
@ -42,7 +42,7 @@ export class GalleryComponent implements OnInit, OnDestroy {
second: number;
} = null;
public readonly mapEnabled: boolean;
public directoryContent: DirectoryContent;
public directoryContent: GroupedDirectoryContent;
public readonly mediaObs: Observable<MediaDTO[]>;
private $counter: Observable<number>;
private subscription: { [key: string]: Subscription } = {
@ -53,17 +53,17 @@ export class GalleryComponent implements OnInit, OnDestroy {
};
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,
private piTitleService: PiTitleService,
private gpxFilesFilterPipe: GPXFilesFilterPipe,
private mdFilesFilterPipe:MDFilesFilterPipe,
public galleryService: ContentService,
private authService: AuthenticationService,
private router: Router,
private shareService: ShareService,
private route: ActivatedRoute,
private navigation: NavigationService,
private filterService: FilterService,
private sortingService: GallerySortingService,
private piTitleService: PiTitleService,
private gpxFilesFilterPipe: GPXFilesFilterPipe,
private mdFilesFilterPipe: MDFilesFilterPipe,
) {
this.mapEnabled = Config.Map.enabled;
PageHelper.showScrollY();
@ -79,17 +79,17 @@ export class GalleryComponent implements OnInit, OnDestroy {
}
// if the timer is longer than 10 years, just do not show it
if (
(this.shareService.sharingSubject.value.expires - Date.now()) /
1000 /
86400 /
365 >
10
(this.shareService.sharingSubject.value.expires - Date.now()) /
1000 /
86400 /
365 >
10
) {
return;
}
t = Math.floor(
(this.shareService.sharingSubject.value.expires - Date.now()) / 1000
(this.shareService.sharingSubject.value.expires - Date.now()) / 1000
);
this.countDown = {} as any;
this.countDown.day = Math.floor(t / 86400);
@ -119,33 +119,33 @@ export class GalleryComponent implements OnInit, OnDestroy {
async ngOnInit(): Promise<boolean> {
await this.shareService.wait();
if (
!this.authService.isAuthenticated() &&
(!this.shareService.isSharing() ||
(this.shareService.isSharing() &&
Config.Sharing.passwordProtected === true))
!this.authService.isAuthenticated() &&
(!this.shareService.isSharing() ||
(this.shareService.isSharing() &&
Config.Sharing.passwordProtected === true))
) {
return this.navigation.toLogin();
}
this.showSearchBar = this.authService.canSearch();
this.showShare =
Config.Sharing.enabled &&
this.authService.isAuthorized(UserRoles.User);
Config.Sharing.enabled &&
this.authService.isAuthorized(UserRoles.User);
this.showRandomPhotoBuilder =
Config.RandomPhoto.enabled &&
this.authService.isAuthorized(UserRoles.User);
Config.RandomPhoto.enabled &&
this.authService.isAuthorized(UserRoles.User);
this.subscription.content = this.sortingService
.applySorting(
this.filterService.applyFilters(this.galleryService.directoryContent)
)
.subscribe((dc: DirectoryContent) => {
this.onContentChange(dc);
});
.applySorting(
this.filterService.applyFilters(this.galleryService.directoryContent)
)
.subscribe((dc: GroupedDirectoryContent) => {
this.onContentChange(dc);
});
this.subscription.route = this.route.params.subscribe(this.onRoute);
if (this.shareService.isSharing()) {
this.$counter = interval(1000);
this.subscription.timer = this.$counter.subscribe((x): void =>
this.updateTimer(x)
this.updateTimer(x)
);
}
}
@ -159,18 +159,18 @@ export class GalleryComponent implements OnInit, OnDestroy {
}
if (
params[QueryParams.gallery.sharingKey_params] &&
params[QueryParams.gallery.sharingKey_params] !== ''
params[QueryParams.gallery.sharingKey_params] &&
params[QueryParams.gallery.sharingKey_params] !== ''
) {
const sharing = await this.shareService.currentSharing
.pipe(take(1))
.toPromise();
.pipe(take(1))
.toPromise();
const qParams: { [key: string]: any } = {};
qParams[QueryParams.gallery.sharingKey_query] =
this.shareService.getSharingKey();
this.shareService.getSharingKey();
this.router
.navigate(['/gallery', sharing.path], {queryParams: qParams})
.catch(console.error);
.navigate(['/gallery', sharing.path], {queryParams: qParams})
.catch(console.error);
return;
}
@ -181,22 +181,21 @@ export class GalleryComponent implements OnInit, OnDestroy {
this.galleryService.loadDirectory(directoryName);
};
private onContentChange = (content: DirectoryContent): void => {
private onContentChange = (content: GroupedDirectoryContent): void => {
if (!content) {
return;
}
this.directoryContent = content;
// enforce change detection on grid
this.directoryContent.media = this.directoryContent.media?.slice();
this.directoryContent.mediaGroups = this.directoryContent.mediaGroups?.slice();
this.isPhotoWithLocation = false;
for (const media of content.media as PhotoDTO[]) {
for (const mediaGroup of content.mediaGroups) {
if (
media.metadata &&
media.metadata.positionData &&
media.metadata.positionData.GPSData &&
media.metadata.positionData.GPSData.longitude
mediaGroup.media
.findIndex((m: PhotoDTO) => !!m.metadata?.positionData?.GPSData?.longitude) !== -1
) {
this.isPhotoWithLocation = true;
break;
@ -204,11 +203,11 @@ export class GalleryComponent implements OnInit, OnDestroy {
}
};
get ShowMarkDown():boolean{
return this.config.MetaFile.markdown && this.directoryContent?.metaFile && this.mdFilesFilterPipe.transform(this.directoryContent.metaFile).length>0;
get ShowMarkDown(): boolean {
return this.config.MetaFile.markdown && this.directoryContent?.metaFile && this.mdFilesFilterPipe.transform(this.directoryContent.metaFile).length > 0;
}
get ShowMap():boolean{
get ShowMap(): boolean {
return (this.isPhotoWithLocation || this.gpxFilesFilterPipe.transform(this.directoryContent?.metaFile)?.length > 0) && this.mapEnabled;
}
}

View File

@ -1,10 +1,7 @@
import { Media } from '../Media';
import {
MediaDTO,
MediaDTOUtils,
} from '../../../../../common/entities/MediaDTO';
import { PhotoDTO } from '../../../../../common/entities/PhotoDTO';
import { VideoDTO } from '../../../../../common/entities/VideoDTO';
import {Media} from '../Media';
import {MediaDTO, MediaDTOUtils,} from '../../../../../common/entities/MediaDTO';
import {PhotoDTO} from '../../../../../common/entities/PhotoDTO';
import {VideoDTO} from '../../../../../common/entities/VideoDTO';
export class GridMedia extends Media {
constructor(

View File

@ -1,4 +1,4 @@
import { MediaDTO } from '../../../../../common/entities/MediaDTO';
import {MediaDTO} from '../../../../../common/entities/MediaDTO';
export class GridRowBuilder {
private photoRow: MediaDTO[] = [];
@ -6,15 +6,15 @@ export class GridRowBuilder {
private photoIndex = 0; // index of the last pushed media to the photoRow
constructor(
private photos: MediaDTO[],
private startIndex: number,
private photoMargin: number,
private containerWidth: number
private photos: MediaDTO[],
private startIndex: number,
private photoMargin: number,
private containerWidth: number
) {
this.photoIndex = startIndex;
if (this.containerWidth <= 0) {
throw new Error(
'container width cant be <=0, got:' + this.containerWidth
'container width cant be <=0, got:' + this.containerWidth
);
}
}
@ -60,14 +60,15 @@ export class GridRowBuilder {
width += (size.width / size.height) || 1; // summing up aspect ratios, NaN should be treated as square photo
}
const height =
(this.containerWidth -
this.photoRow.length * (this.photoMargin * 2) -
1) /
width; // cant be equal -> width-1
(this.containerWidth -
this.photoRow.length * (this.photoMargin * 2) -
1) /
width; // cant be equal -> width-1
return height + this.photoMargin * 2;
}
private addPhoto(): boolean {
if (this.photoIndex + 1 > this.photos.length) {
return false;

View File

@ -3,8 +3,7 @@
width: 100%;
}
div {
/*display: block;*/
.media-grid {
line-height: normal;
font-size: 0;
}

View File

@ -1,11 +1,19 @@
<div #gridContainer [style.width]="renderDelayTimer ? containerWidth+'px' : ''">
<app-gallery-grid-photo
*ngFor="let gridPhoto of photosToRender"
(click)="photoClicked(gridPhoto.media)"
[gridMedia]="gridPhoto"
[style.width.px]="gridPhoto.renderWidth"
[style.height.px]="gridPhoto.renderHeight"
[style.margin-left.px]="IMAGE_MARGIN"
[style.margin-right.px]="IMAGE_MARGIN">
</app-gallery-grid-photo>
<ng-container *ngIf="mediaToRender?.length > 0">
<ng-container *ngFor="let group of mediaToRender">
<div *ngIf="group.name" class="mt-4 mb-3"><h6 class="ms-2">{{group.name}}</h6></div>
<div class="media-grid">
<app-gallery-grid-photo
*ngFor="let gridPhoto of group.media"
(click)="photoClicked(gridPhoto.media)"
[gridMedia]="gridPhoto"
[style.width.px]="gridPhoto.renderWidth"
[style.height.px]="gridPhoto.renderHeight"
[style.margin-left.px]="IMAGE_MARGIN"
[style.margin-right.px]="IMAGE_MARGIN">
</app-gallery-grid-photo>
</div>
</ng-container>
</ng-container>
</div>

View File

@ -25,6 +25,8 @@ import {QueryService} from '../../../model/query.service';
import {ContentService} from '../content.service';
import {MediaDTO, MediaDTOUtils,} from '../../../../../common/entities/MediaDTO';
import {QueryParams} from '../../../../../common/QueryParams';
import {MediaGroup} from '../navigator/sorting.service';
import {SimpleChanges} from '../../../../../../node_modules/@angular/core';
@Component({
selector: 'app-gallery-grid',
@ -32,13 +34,13 @@ import {QueryParams} from '../../../../../common/QueryParams';
styleUrls: ['./grid.gallery.component.css'],
})
export class GalleryGridComponent
implements OnInit, OnChanges, AfterViewInit, OnDestroy {
implements OnInit, OnChanges, AfterViewInit, OnDestroy {
@ViewChild('gridContainer', {static: false}) gridContainer: ElementRef;
@ViewChildren(GalleryPhotoComponent)
gridPhotoQL: QueryList<GalleryPhotoComponent>;
@Input() lightbox: GalleryLightboxComponent;
@Input() media: MediaDTO[];
photosToRender: GridMedia[] = [];
@Input() mediaGroups: MediaGroup[];
mediaToRender: GridMediaGroup[] = [];
containerWidth = 0;
screenHeight = 0;
public IMAGE_MARGIN = 2;
@ -56,37 +58,37 @@ export class GalleryGridComponent
private onScrollFired = false;
private helperTime: number = null;
public renderDelayTimer: number = null; // delays render on resize
private renderedPhotoIndex = 0;
constructor(
private overlayService: OverlayService,
private changeDetector: ChangeDetectorRef,
public queryService: QueryService,
private router: Router,
public galleryService: ContentService,
private route: ActivatedRoute
private overlayService: OverlayService,
private changeDetector: ChangeDetectorRef,
public queryService: QueryService,
private router: Router,
public galleryService: ContentService,
private route: ActivatedRoute
) {
}
ngOnChanges(): void {
ngOnChanges(changes: SimpleChanges): void {
console.log(changes);
this.onChange();
}
ngOnInit(): void {
this.subscriptions.route = this.route.queryParams.subscribe(
(params: Params): void => {
if (
params[QueryParams.gallery.photo] &&
params[QueryParams.gallery.photo] !== ''
) {
this.delayedRenderUpToPhoto = params[QueryParams.gallery.photo];
if (!this.media || this.media.length === 0) {
return;
}
this.renderUpToMedia(params[QueryParams.gallery.photo]);
(params: Params): void => {
if (
params[QueryParams.gallery.photo] &&
params[QueryParams.gallery.photo] !== ''
) {
this.delayedRenderUpToPhoto = params[QueryParams.gallery.photo];
if (!this.mediaGroups?.length) {
return;
}
this.renderUpToMedia(params[QueryParams.gallery.photo]);
}
}
);
}
@ -95,6 +97,7 @@ export class GalleryGridComponent
return;
}
this.updateContainerDimensions();
this.mergeNewPhotos();
this.helperTime = window.setTimeout((): void => {
this.renderPhotos();
if (this.delayedRenderUpToPhoto) {
@ -128,13 +131,13 @@ export class GalleryGridComponent
}
this.renderDelayTimer = window.setTimeout(() => {
this.renderDelayTimer = null;
// render the same amount of images on resize
const renderedIndex = this.renderedPhotoIndex;
// render the same amount of images after resize
const renderedCount = this.mediaToRender.reduce((c, mg) => c + mg.media.length, 0);
// do not rerender if container is not changes
if (this.updateContainerDimensions() === false) {
return;
}
this.renderPhotos(renderedIndex);
this.renderPhotos(renderedCount);
}, 100);
}
@ -150,7 +153,7 @@ export class GalleryGridComponent
if (Config.Gallery.enableOnScrollThumbnailPrioritising === true) {
this.gridPhotoQL.changes.subscribe((): void => {
this.scrollListenerPhotos = this.gridPhotoQL.filter(
(pc): boolean => pc.ScrollListener
(pc): boolean => pc.ScrollListener
);
});
}
@ -166,22 +169,78 @@ export class GalleryGridComponent
this.isAfterViewInit = true;
}
// TODO: This is deprecated,
// we do not post update galleries anymore since the preview member in the DriectoryDTO
private mergeNewPhotos(): void {
// merge new data with old one
const lastSameIndex = {groups: 0, media: 0};
let lastRowId = 0;
let diffFound = false;
for (let i = 0; i < this.mediaGroups.length && i < this.mediaToRender.length; ++i) {
for (let j = 0; j < this.mediaGroups[i].media.length && j < this.mediaToRender[i].media.length; ++j) {
const media = this.mediaGroups[i].media[j];
const gridMedia = this.mediaToRender[i].media[j];
// If a media changed the whole row has to be removed
if (gridMedia.rowId !== lastRowId) {
lastSameIndex.groups = i;
lastSameIndex.media = j;
lastRowId = gridMedia.rowId;
}
if (gridMedia.equals(media) === false) {
diffFound = true;
break;
}
}
// delete last row if the length of the two are not equal
if (!diffFound && this.mediaGroups[i].media.length < this.mediaToRender[i].media.length) {
lastRowId = this.mediaToRender[i].media[this.mediaToRender[i].media.length].rowId;
for (let j = this.mediaToRender[i].media.length - 2; j >= 0; --j) {
const gridMedia = this.mediaToRender[i].media[j];
if (gridMedia.rowId !== lastRowId) {
lastSameIndex.groups = i;
lastSameIndex.media = j + 1;
}
}
}
}
// if all the same
if (diffFound) {
if (lastSameIndex.media == 0 && lastSameIndex.groups == 0) {
this.clearRenderedPhotos();
return;
}
this.mediaToRender.splice(lastSameIndex.groups, this.mediaToRender.length - lastSameIndex.groups);
const media = this.mediaToRender[lastSameIndex.groups].media;
media.splice(lastSameIndex.media, media.length - lastSameIndex.media);
}
}
public renderARow(): number {
if (
this.renderedPhotoIndex >= this.media.length ||
this.containerWidth === 0
!this.isMoreToRender() ||
this.containerWidth === 0
) {
return null;
}
// step group
if (this.mediaToRender.length == 0 ||
this.mediaToRender[this.mediaToRender.length - 1].media.length >=
this.mediaGroups[this.mediaToRender.length - 1].media.length) {
this.mediaToRender.push({name: this.mediaGroups[this.mediaToRender.length].name, media: []});
}
let maxRowHeight = this.getMaxRowHeight();
const minRowHeight = this.screenHeight / this.MAX_ROW_COUNT;
const photoRowBuilder = new GridRowBuilder(
this.media,
this.renderedPhotoIndex,
this.IMAGE_MARGIN,
this.containerWidth - this.overlayService.getPhantomScrollbarWidth()
this.mediaGroups[this.mediaToRender.length - 1].media,
this.mediaToRender[this.mediaToRender.length - 1].media.length,
this.IMAGE_MARGIN,
this.containerWidth - this.overlayService.getPhantomScrollbarWidth()
);
photoRowBuilder.addPhotos(this.TARGET_COL_COUNT);
@ -191,40 +250,43 @@ export class GalleryGridComponent
if (photoRowBuilder.getPhotoRow().length > 1) {
maxRowHeight *= 1.2;
}
const rowHeight = Math.min(photoRowBuilder.calcRowHeight(), maxRowHeight);
const noFullRow = photoRowBuilder.calcRowHeight() > maxRowHeight;
// if the row is not full, make it average sized
const rowHeight = noFullRow ? (minRowHeight + maxRowHeight) / 2 :
Math.min(photoRowBuilder.calcRowHeight(), maxRowHeight);
const imageHeight = rowHeight - this.IMAGE_MARGIN * 2;
photoRowBuilder.getPhotoRow().forEach((photo): void => {
const imageWidth = imageHeight * MediaDTOUtils.calcAspectRatio(photo);
this.photosToRender.push(
new GridMedia(photo, imageWidth, imageHeight, this.renderedPhotoIndex)
photoRowBuilder.getPhotoRow().forEach((media): void => {
const imageWidth = imageHeight * MediaDTOUtils.calcAspectRatio(media);
this.mediaToRender[this.mediaToRender.length - 1].media.push(
new GridMedia(media, imageWidth, imageHeight, this.mediaToRender[this.mediaToRender.length - 1].media.length)
);
});
this.renderedPhotoIndex += photoRowBuilder.getPhotoRow().length;
//this.renderedPhotoIndex += photoRowBuilder.getPhotoRow().length;
return rowHeight;
}
@HostListener('window:scroll')
onScroll(): void {
if (
!this.onScrollFired &&
this.media &&
// should we trigger this at all?
(this.renderedPhotoIndex < this.media.length ||
this.scrollListenerPhotos.length > 0)
!this.onScrollFired &&
this.mediaGroups &&
// should we trigger this at all?
(this.isMoreToRender() ||
this.scrollListenerPhotos.length > 0)
) {
window.requestAnimationFrame((): void => {
this.renderPhotos();
if (Config.Gallery.enableOnScrollThumbnailPrioritising === true) {
this.scrollListenerPhotos.forEach(
(pc: GalleryPhotoComponent): void => {
pc.onScroll();
}
(pc: GalleryPhotoComponent): void => {
pc.onScroll();
}
);
this.scrollListenerPhotos = this.scrollListenerPhotos.filter(
(pc): boolean => pc.ScrollListener
(pc): boolean => pc.ScrollListener
);
}
@ -242,28 +304,36 @@ export class GalleryGridComponent
* Makes sure that the photo with the given mediaString is visible on the screen
*/
private renderUpToMedia(mediaStringId: string): void {
const index = this.media.findIndex(
let groupIndex = -1;
let mediaIndex = -1;
for (let i = 0; i < this.mediaGroups.length; ++i) {
mediaIndex = this.mediaGroups[i].media.findIndex(
(p): boolean => this.queryService.getMediaStringId(p) === mediaStringId
);
if (index === -1) {
);
if (mediaIndex !== -1) {
groupIndex = i;
break;
}
}
if (groupIndex === -1) {
this.router.navigate([], {queryParams: this.queryService.getParams()});
return;
}
// Make sure that at leas one more photo is rendered
// Make sure that at leas one more row is rendered
// It is possible that only the last few pixels of a photo is visible,
// so not required to render more, but the scrollbar does not trigger more photos to render
// (on ligthbox navigation)
// (on lightbox navigation)
while (
this.renderedPhotoIndex - 1 < index + 1 &&
this.renderARow() !== null
// eslint-disable-next-line no-empty
) {
(this.mediaToRender.length - 1 < groupIndex &&
this.mediaToRender[this.mediaToRender.length - 1].media.length < mediaIndex) &&
this.renderARow() !== null
// eslint-disable-next-line no-empty
) {
}
}
private clearRenderedPhotos(): void {
this.photosToRender = [];
this.renderedPhotoIndex = 0;
this.mediaToRender = [];
this.changeDetector.detectChanges();
}
@ -276,24 +346,24 @@ export class GalleryGridComponent
private shouldRenderMore(offset = 0): boolean {
const bottomOffset = this.getMaxRowHeight() * 2;
return (
Config.Gallery.enableOnScrollRendering === false ||
PageHelper.ScrollY >=
document.body.clientHeight +
offset -
window.innerHeight -
bottomOffset ||
(document.body.clientHeight + offset) * 0.85 < window.innerHeight
Config.Gallery.enableOnScrollRendering === false ||
PageHelper.ScrollY >=
document.body.clientHeight +
offset -
window.innerHeight -
bottomOffset ||
(document.body.clientHeight + offset) * 0.85 < window.innerHeight
);
}
private renderPhotos(numberOfPhotos = 0): void {
if (!this.media) {
if (!this.mediaGroups) {
return;
}
if (
this.containerWidth === 0 ||
this.renderedPhotoIndex >= this.media.length ||
!this.shouldRenderMore()
this.containerWidth === 0 ||
!this.isMoreToRender() ||
!this.shouldRenderMore()
) {
return;
}
@ -301,10 +371,10 @@ export class GalleryGridComponent
let renderedContentHeight = 0;
while (
this.renderedPhotoIndex < this.media.length &&
(this.shouldRenderMore(renderedContentHeight) === true ||
this.renderedPhotoIndex < numberOfPhotos)
) {
this.isMoreToRender() &&
(this.shouldRenderMore(renderedContentHeight) === true ||
this.getNumberOfRenderedMedia() < numberOfPhotos)
) {
const ret = this.renderARow();
if (ret === null) {
throw new Error('Grid media rendering failed');
@ -313,6 +383,15 @@ export class GalleryGridComponent
}
}
private isMoreToRender() {
return this.mediaToRender.length < this.mediaGroups.length ||
(this.mediaToRender[this.mediaToRender.length - 1]?.media.length || 0) < this.mediaGroups[this.mediaToRender.length - 1]?.media.length;
}
getNumberOfRenderedMedia() {
return this.mediaToRender.reduce((c, mg) => c + mg.media.length, 0);
}
private updateContainerDimensions(): boolean {
if (!this.gridContainer) {
return false;
@ -322,9 +401,9 @@ export class GalleryGridComponent
PageHelper.showScrollY();
// if the width changed a bit or the height changed a lot
if (
this.containerWidth !== this.gridContainer.nativeElement.parentElement.clientWidth ||
this.screenHeight < window.innerHeight * 0.75 ||
this.screenHeight > window.innerHeight * 1.25
this.containerWidth !== this.gridContainer.nativeElement.parentElement.clientWidth ||
this.screenHeight < window.innerHeight * 0.75 ||
this.screenHeight > window.innerHeight * 1.25
) {
this.screenHeight = window.innerHeight;
this.containerWidth = this.gridContainer.nativeElement.parentElement.clientWidth;
@ -341,3 +420,8 @@ export class GalleryGridComponent
return false;
}
}
interface GridMediaGroup {
media: GridMedia[];
name: string;
}

View File

@ -11,9 +11,6 @@
display: inline-block;
}
#sorting-dropdown .dropdown-item.row {
flex-wrap: nowrap;
}
.dropdown-item {
padding: 0.25rem 0.25rem;
@ -46,8 +43,9 @@ app-gallery-filter {
border: none;
}
.sorting-item {
.sorting-grouping-item {
cursor: pointer;
flex-wrap: nowrap;
}

View File

@ -61,17 +61,37 @@
aria-controls="sorting-dropdown">
<app-sorting-method-icon [method]="sortingService.sorting.value"></app-sorting-method-icon>
</button>
<ul id="sorting-dropdown" *dropdownMenu class="dropdown-menu dropdown-menu-right"
role="menu" aria-labelledby="button-alignment">
<li class="row dropdown-item sorting-item" role="menuitem"
*ngFor="let type of sortingMethodsType"
(click)="setSorting(type.key)">
<div class="col-3">
<app-sorting-method-icon [method]="type.key"></app-sorting-method-icon>
<div id="sorting-dropdown" *dropdownMenu class="dropdown-menu dropdown-menu-right"
role="menu" aria-labelledby="button-alignment">
<div class="row flex-nowrap">
<div class="col-6">
Sorting
<div class="row">
<div class="dropdown-item sorting-grouping-item" role="menuitem"
*ngFor="let type of sortingMethodsType"
(click)="setSorting(type.key)">
<div class="me-2 d-inline-block">
<app-sorting-method-icon [method]="type.key"></app-sorting-method-icon>
</div>
<div class="d-inline-block">{{type.key | stringifySorting}}</div>
</div>
</div>
</div>
<div class="col-9">{{type.key | stringifySorting}}</div>
</li>
</ul>
<div class="col-6">
Grouping
<div class="row">
<div class="dropdown-item sorting-grouping-item" role="menuitem"
*ngFor="let type of sortingMethodsType"
(click)="setGrouping(type.key)">
<div class="me-2 d-inline-block">
<app-sorting-method-icon [method]="type.key"></app-sorting-method-icon>
</div>
<div class="d-inline-block">{{type.key | stringifySorting}}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>

View File

@ -145,6 +145,10 @@ export class GalleryNavigatorComponent {
setSorting(sorting: SortingMethods): void {
this.sortingService.setSorting(sorting);
this.sortingService.setGrouping(sorting);
}
setGrouping(grouping: SortingMethods): void {
this.sortingService.setGrouping(grouping);
}
getDownloadZipLink(): string {
@ -215,6 +219,7 @@ export class GalleryNavigatorComponent {
}
this.lastScroll.any = scrollPosition;
}
}
interface NavigatorPath {

View File

@ -1,4 +1,5 @@
import {Injectable} from '@angular/core';
import {DatePipe} from '@angular/common';
import {NetworkService} from '../../../model/network/network.service';
import {GalleryCacheService} from '../cache.gallery.service';
import {BehaviorSubject, Observable} from 'rxjs';
@ -10,21 +11,29 @@ import {PhotoDTO} from '../../../../../common/entities/PhotoDTO';
import {map, switchMap} from 'rxjs/operators';
import {SeededRandomService} from '../../../model/seededRandom.service';
import {ContentWrapper} from '../../../../../common/entities/ConentWrapper';
import {SubDirectoryDTO} from '../../../../../common/entities/DirectoryDTO';
import {MediaDTO} from '../../../../../common/entities/MediaDTO';
import {FileDTO} from '../../../../../common/entities/FileDTO';
@Injectable()
export class GallerySortingService {
public sorting: BehaviorSubject<SortingMethods>;
public grouping: BehaviorSubject<SortingMethods>;
private collator = new Intl.Collator(undefined, {numeric: true});
constructor(
private networkService: NetworkService,
private galleryCacheService: GalleryCacheService,
private galleryService: ContentService,
private rndService: SeededRandomService
private rndService: SeededRandomService,
private datePipe: DatePipe
) {
this.sorting = new BehaviorSubject<SortingMethods>(
Config.Gallery.defaultPhotoSortingMethod
);
this.grouping = new BehaviorSubject<SortingMethods>(
SortingMethods.ascDate // TODO: move to config
);
this.galleryService.content.subscribe((c) => {
if (c) {
const sort = this.galleryCacheService.getSorting(c);
@ -70,146 +79,196 @@ export class GallerySortingService {
}
}
setGrouping(grouping: SortingMethods): void {
this.grouping.next(grouping);
}
private sortMedia(sorting: SortingMethods, media: MediaDTO[]): void {
if (!media) {
return;
}
switch (sorting) {
case SortingMethods.ascName:
media.sort((a: PhotoDTO, b: PhotoDTO) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.descName:
media.sort((a: PhotoDTO, b: PhotoDTO) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.ascDate:
media.sort((a: PhotoDTO, b: PhotoDTO): number => {
return a.metadata.creationDate - b.metadata.creationDate;
});
break;
case SortingMethods.descDate:
media.sort((a: PhotoDTO, b: PhotoDTO): number => {
return b.metadata.creationDate - a.metadata.creationDate;
});
break;
case SortingMethods.ascRating:
media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(a.metadata.rating || 0) - (b.metadata.rating || 0)
);
break;
case SortingMethods.descRating:
media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(b.metadata.rating || 0) - (a.metadata.rating || 0)
);
break;
case SortingMethods.ascPersonCount:
media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(a.metadata?.faces?.length || 0) - (b.metadata?.faces?.length || 0)
);
break;
case SortingMethods.descPersonCount:
media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(b.metadata?.faces?.length || 0) - (a.metadata?.faces?.length || 0)
);
break;
case SortingMethods.random:
this.rndService.setSeed(media.length);
media.sort((a: PhotoDTO, b: PhotoDTO): number => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
})
.sort((): number => {
return this.rndService.get() - 0.5;
});
break;
}
return;
}
public applySorting(
directoryContent: Observable<DirectoryContent>
): Observable<DirectoryContent> {
): Observable<GroupedDirectoryContent> {
return directoryContent.pipe(
switchMap((dirContent) => {
return this.sorting.pipe(
map((sorting: SortingMethods) => {
if (!dirContent) {
return dirContent;
}
const c = {
media: dirContent.media,
directories: dirContent.directories,
metaFile: dirContent.metaFile,
};
if (c.directories) {
switch (sorting) {
case SortingMethods.ascRating: // directories do not have rating
case SortingMethods.ascName:
c.directories.sort((a, b) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.ascDate:
if (
Config.Gallery.enableDirectorySortingByDate === true
) {
c.directories.sort(
(a, b) => a.lastModified - b.lastModified
);
break;
return this.grouping.pipe(
switchMap((grouping) => {
return this.sorting.pipe(
map((sorting) => {
if (!dirContent) {
return null;
}
const c: GroupedDirectoryContent = {
mediaGroups: [],
directories: dirContent.directories,
metaFile: dirContent.metaFile,
};
if (c.directories) {
switch (sorting) {
case SortingMethods.ascRating: // directories do not have rating
case SortingMethods.ascName:
c.directories.sort((a, b) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.ascDate:
if (
Config.Gallery.enableDirectorySortingByDate === true
) {
c.directories.sort(
(a, b) => a.lastModified - b.lastModified
);
break;
}
c.directories.sort((a, b) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.descRating: // directories do not have rating
case SortingMethods.descName:
c.directories.sort((a, b) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.descDate:
if (
Config.Gallery.enableDirectorySortingByDate === true
) {
c.directories.sort(
(a, b) => b.lastModified - a.lastModified
);
break;
}
c.directories.sort((a, b) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.random:
this.rndService.setSeed(c.directories.length);
c.directories
.sort((a, b): number => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return 1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return -1;
}
return 0;
})
.sort((): number => {
return this.rndService.get() - 0.5;
});
break;
}
c.directories.sort((a, b) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.descRating: // directories do not have rating
case SortingMethods.descName:
c.directories.sort((a, b) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.descDate:
if (
Config.Gallery.enableDirectorySortingByDate === true
) {
c.directories.sort(
(a, b) => b.lastModified - a.lastModified
);
break;
}
// group
if (dirContent.media) {
const mCopy = dirContent.media;
this.sortMedia(grouping, mCopy);
let groupFN = (m: MediaDTO) => '';
switch (grouping) {
case SortingMethods.ascDate:
case SortingMethods.descDate:
groupFN = (m: MediaDTO) => this.datePipe.transform(m.metadata.creationDate, 'longDate');
break;
case SortingMethods.ascName:
case SortingMethods.descName:
groupFN = (m: MediaDTO) => m.name.at(0).toLowerCase();
break;
case SortingMethods.descRating:
case SortingMethods.ascRating:
groupFN = (m: MediaDTO) => ((m as PhotoDTO).metadata.rating || 0).toString();
break;
case SortingMethods.descPersonCount:
case SortingMethods.ascPersonCount:
groupFN = (m: MediaDTO) => ((m as PhotoDTO).metadata.faces || []).length.toString();
break;
}
c.directories.sort((a, b) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.random:
this.rndService.setSeed(c.directories.length);
c.directories
.sort((a, b): number => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return 1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return -1;
}
return 0;
})
.sort((): number => {
return this.rndService.get() - 0.5;
});
break;
}
}
c.mediaGroups = [];
for (const m of mCopy) {
const k = groupFN(m);
if (c.mediaGroups.length == 0 || c.mediaGroups[c.mediaGroups.length - 1].name != k) {
c.mediaGroups.push({name: k, media: []});
}
c.mediaGroups[c.mediaGroups.length - 1].media.push(m);
}
c.mediaGroups;
}
if (c.media) {
switch (sorting) {
case SortingMethods.ascName:
c.media.sort((a: PhotoDTO, b: PhotoDTO) =>
this.collator.compare(a.name, b.name)
);
break;
case SortingMethods.descName:
c.media.sort((a: PhotoDTO, b: PhotoDTO) =>
this.collator.compare(b.name, a.name)
);
break;
case SortingMethods.ascDate:
c.media.sort((a: PhotoDTO, b: PhotoDTO): number => {
return a.metadata.creationDate - b.metadata.creationDate;
});
break;
case SortingMethods.descDate:
c.media.sort((a: PhotoDTO, b: PhotoDTO): number => {
return b.metadata.creationDate - a.metadata.creationDate;
});
break;
case SortingMethods.ascRating:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(a.metadata.rating || 0) - (b.metadata.rating || 0)
);
break;
case SortingMethods.descRating:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(b.metadata.rating || 0) - (a.metadata.rating || 0)
);
break;
case SortingMethods.ascPersonCount:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(a.metadata?.faces?.length || 0) - (b.metadata?.faces?.length || 0)
);
break;
case SortingMethods.descPersonCount:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(b.metadata?.faces?.length || 0) - (a.metadata?.faces?.length || 0)
);
break;
case SortingMethods.random:
this.rndService.setSeed(c.media.length);
c.media
.sort((a: PhotoDTO, b: PhotoDTO): number => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
})
.sort((): number => {
return this.rndService.get() - 0.5;
});
break;
}
}
// sort groups
for (let i = 0; i < c.mediaGroups.length; ++i) {
this.sortMedia(sorting, c.mediaGroups[i].media);
}
return c;
return c;
})
);
})
);
})
@ -217,4 +276,15 @@ export class GallerySortingService {
}
}
export interface MediaGroup {
name: string;
media: MediaDTO[];
}
export interface GroupedDirectoryContent {
directories: SubDirectoryDTO[];
mediaGroups: MediaGroup[];
metaFile: FileDTO[];
}