autowp/autowp-frontend

View on GitHub
src/app/gallery/gallery.component.ts

Summary

Maintainability
A
0 mins
Test Coverage
import {AsyncPipe} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
input,
output,
} from '@angular/core';
import {toObservable} from '@angular/core/rxjs-interop';
import {Router, RouterLink} from '@angular/router';
import {
GalleryRequest,
GalleryResponse,
ItemFields,
ItemParentCacheListOptions,
ItemsRequest,
Picture,
PictureFields,
PictureItemFields,
PictureItemListOptions,
PictureItemsRequest,
PictureItemType,
PictureListOptions,
PicturesRequest,
PictureStatus,
} from '@grpc/spec.pb';
import {PicturesClient} from '@grpc/spec.pbsc';
import {GrpcStatusEvent} from '@ngx-grpc/common';
import {LanguageService} from '@services/language';
import {combineLatest, EMPTY, Observable, of} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, map, shareReplay, switchMap, take, tap} from 'rxjs/operators';
 
import {ToastsService} from '../toasts/toasts.service';
import {CarouselItemComponent} from './carousel-item.component';
 
const galleryFields = new PictureFields({
commentsCount: true,
image: true,
imageGallery: true,
imageGalleryFull: true,
nameHtml: true,
nameText: true,
pictureItem: new PictureItemsRequest({
fields: new PictureItemFields({
item: new ItemsRequest({
fields: new ItemFields({nameHtml: true}),
}),
}),
options: new PictureItemListOptions({
hasArea: true,
typeId: PictureItemType.PICTURE_ITEM_CONTENT,
}),
}),
});
 
export interface APIGalleryFilter {
exactItemID?: string;
exactItemLinkType?: number;
itemID?: string;
perspectiveExclude?: number[];
perspectiveID?: number;
}
 
class Gallery {
readonly #MAX_INDICATORS = 30;
readonly #PER_PAGE = 10;
 
public current = 0;
public status: PictureStatus = PictureStatus.PICTURE_STATUS_UNKNOWN;
public get useCircleIndicator(): boolean {
return this.items.length <= this.#MAX_INDICATORS;
}
 
constructor(
public readonly filter: APIGalleryFilter,
public readonly items: (null | Picture)[],
) {}
 
public filterParams(language: string): PicturesRequest {
const options = new PictureListOptions({
status: PictureStatus.PICTURE_STATUS_ACCEPTED,
});
 
let order = PicturesRequest.Order.ORDER_RESOLUTION_DESC;
if (this.filter.itemID || this.filter.exactItemID) {
order = PicturesRequest.Order.ORDER_PERSPECTIVES;
}
 
if (
this.filter.itemID ||
this.filter.exactItemID ||
this.filter.exactItemLinkType ||
this.filter.perspectiveID ||
this.filter.perspectiveExclude
) {
options.pictureItem = new PictureItemListOptions({
excludePerspectiveId: this.filter.perspectiveExclude,
itemId: this.filter.exactItemID,
itemParentCacheAncestor: this.filter.itemID
? new ItemParentCacheListOptions({
parentId: this.filter.itemID,
})
: undefined,
perspectiveId: this.filter.perspectiveID,
typeId: this.filter.exactItemLinkType,
});
}
 
return new PicturesRequest({
fields: galleryFields,
language,
options,
order,
});
}
 
public getItemIndex(identity: string): number {
return this.items.findIndex((item) => item && item.identity === identity);
}
 
public getItemByIndex(index: number): null | Picture {
if (index < 0 || index >= this.items.length) {
return null;
}
 
if (!this.items[index]) {
return null;
}
 
return this.items[index];
}
 
public getGalleryItem(identity: string): null | Picture {
const index = this.getItemIndex(identity);
if (index < 0) {
return null;
}
 
return this.getItemByIndex(index);
}
 
public applyResponse(response: GalleryResponse) {
if (this.items.length < response.count) {
this.items[response.count - 1] = null;
this.status = response.status;
}
 
(response.items || []).forEach((item, i) => {
const index = (response.page - 1) * this.#PER_PAGE + i;
this.items[index] = item;
});
}
 
public getGalleryPageNumberByIndex(index: number) {
return Math.floor(index / this.#PER_PAGE) + 1;
}
}
 
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CarouselItemComponent, RouterLink, AsyncPipe],
selector: 'app-gallery',
styleUrls: ['./gallery.component.scss'],
templateUrl: './gallery.component.html',
})
export class GalleryComponent {
readonly #router = inject(Router);
readonly #picturesClient = inject(PicturesClient);
readonly #languageService = inject(LanguageService);
readonly #toastService = inject(ToastsService);
readonly #cdr = inject(ChangeDetectorRef);
 
readonly filter = input.required<APIGalleryFilter>();
 
readonly current = input.required<null | string>();
protected readonly current$ = toObservable(this.current);
 
readonly galleryPrefix = input.required<string[]>();
readonly picturePrefix = input.required<string[]>();
readonly pictureSelected = output<null | Picture>();
 
protected readonly currentFilter$ = toObservable(this.filter).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
debounceTime(50),
shareReplay({bufferSize: 1, refCount: false}),
);
 
protected readonly identity$ = this.current$.pipe(
distinctUntilChanged(),
debounceTime(10),
shareReplay({bufferSize: 1, refCount: false}),
);
 
protected readonly gallery$: Observable<Gallery> = combineLatest([
this.currentFilter$.pipe(switchMap((filter) => (filter ? of(new Gallery(filter, [] as Picture[])) : EMPTY))),
this.identity$.pipe(switchMap((identity) => (identity ? of(identity) : EMPTY))),
]).pipe(
switchMap(([gallery, identity]) => {
if (!gallery.getGalleryItem(identity)) {
return this.#picturesClient
.getGallery(
new GalleryRequest({
pictureIdentity: identity,
request: gallery.filterParams(this.#languageService.language),
}),
)
.pipe(
catchError((response: unknown) => {
if (response instanceof GrpcStatusEvent && response.statusCode === 5) {
this.#router.navigate(['/error-404'], {
skipLocationChange: true,
});
} else {
this.#toastService.handleError(response);
}
return EMPTY;
}),
tap((response) => {
gallery.applyResponse(response);
}),
map(() => ({gallery, identity})),
);
}
return of({gallery, identity});
}),
tap(({gallery, identity}) => {
const index = gallery.getItemIndex(identity);
gallery.current = index;
const currentItem = gallery.getItemByIndex(index);
this.pictureSelected.emit(currentItem);
this.#cdr.markForCheck();
}),
map(({gallery}) => gallery),
shareReplay({bufferSize: 1, refCount: false}),
);
 
@HostListener('document:keydown.escape')
onKeydownHandler() {
this.current$
.pipe(
take(1),
switchMap((current) => (current ? this.#router.navigate(this.picturePrefix().concat([current])) : EMPTY)),
)
.subscribe();
}
 
@HostListener('document:keydown.arrowright')
onRightKeydownHandler() {
this.gallery$.pipe(take(1)).subscribe((gallery) => {
if (gallery.current + 1 < gallery.items.length) {
this.navigateToIndex(gallery.current + 1, gallery);
}
});
}
 
@HostListener('document:keydown.arrowleft')
onLeftKeydownHandler() {
this.gallery$.pipe(take(1)).subscribe((gallery) => {
if (gallery.current > 0) {
this.navigateToIndex(gallery.current - 1, gallery);
}
});
}
 
private loadPage$(page: number, gallery: Gallery): Observable<GalleryResponse> {
const request = gallery.filterParams(this.#languageService.language);
request.options!.status = gallery.status;
request.page = page;
 
return this.#picturesClient.getGallery(new GalleryRequest({request})).pipe(
catchError((response: unknown) => {
if (response instanceof GrpcStatusEvent && response.statusCode === 5) {
this.#router.navigate(['/error-404'], {
skipLocationChange: true,
});
} else {
this.#toastService.handleError(response);
}
return EMPTY;
}),
tap((response) => {
gallery.applyResponse(response);
}),
);
}
 
protected navigateToIndex(index: number, gallery: Gallery): void {
const item = gallery.getItemByIndex(index);
if (item) {
this.#router.navigate(this.galleryPrefix().concat([item.identity]));
return;
}
 
const page = gallery.getGalleryPageNumberByIndex(index);
this.loadPage$(page, gallery).subscribe(() => {
const sitem = gallery.getItemByIndex(index);
if (sitem) {
this.#router.navigate(this.galleryPrefix().concat([sitem.identity]));
}
});
}
}