Kavita/UI/Web/src/app/library-detail/library-detail.component.ts
Joe Milazzo c52ed1f65d
Browse by Genre/Tag/Person with new metadata system for People (#3835)
Co-authored-by: Stepan Goremykin <s.goremykin@proton.me>
Co-authored-by: goremykin <goremukin@gmail.com>
Co-authored-by: Christopher <39032787+MrRobotjs@users.noreply.github.com>
Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
2025-06-14 10:14:04 -07:00

348 lines
13 KiB
TypeScript

import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
HostListener,
inject,
OnInit
} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {take} from 'rxjs/operators';
import {BulkSelectionService} from '../cards/bulk-selection.service';
import {KEY_CODES, UtilityService} from '../shared/_services/utility.service';
import {SeriesAddedEvent} from '../_models/events/series-added-event';
import {Library} from '../_models/library/library';
import {Pagination} from '../_models/pagination';
import {Series} from '../_models/series';
import {FilterEvent, SortField} from '../_models/metadata/series-filter';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
import {ActionService} from '../_services/action.service';
import {LibraryService} from '../_services/library.service';
import {EVENTS, MessageHubService} from '../_services/message-hub.service';
import {SeriesService} from '../_services/series.service';
import {NavService} from '../_services/nav.service';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {JumpKey} from '../_models/jumpbar/jump-key';
import {SeriesRemovedEvent} from '../_models/events/series-removed-event';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
import {DecimalPipe} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@jsverse/transloco";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {debounceTime, ReplaySubject, tap} from "rxjs";
import {SeriesFilterSettings} from "../metadata-filter/filter-settings";
import {MetadataService} from "../_services/metadata.service";
@Component({
selector: 'app-library-detail',
templateUrl: './library-detail.component.html',
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent,
CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, DecimalPipe, TranslocoDirective, LoadingComponent]
})
export class LibraryDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly seriesService = inject(SeriesService);
private readonly libraryService = inject(LibraryService);
private readonly titleService = inject(Title);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
public readonly navService = inject(NavService);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly metadataService = inject(MetadataService);
libraryId!: number;
libraryName = '';
series: Series[] = [];
loadingSeries = false;
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
actions: ActionItem<Library>[] = [];
filter: FilterV2<FilterField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActive: boolean = false;
filterActiveCheck!: FilterV2<FilterField>;
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
bulkLoader: boolean = false;
tabs: Array<{title: string, fragment: string, icon: string}> = [
{title: 'library-tab', fragment: '', icon: 'fa-landmark'},
{title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'},
];
active = this.tabs[0];
loadPageSource = new ReplaySubject(1);
loadPage$ = this.loadPageSource.asObservable();
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + ''));
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToCollection:
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.MarkAsRead:
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.MarkAsUnread:
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.Delete:
if (selectedSeries.length > 25) {
this.bulkLoader = true;
this.cdRef.markForCheck();
}
await this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
this.bulkLoader = false;
this.cdRef.markForCheck();
if (!successful) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
});
break;
case Action.SetReadingProfile:
this.actionService.setReadingProfileForMultiple(selectedSeries, (success) => {
this.bulkLoader = false;
this.cdRef.markForCheck();
if (!success) return;
this.bulkSelectionService.deselectAll();
this.loadPage();
})
}
}
constructor() {
const routeId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null) {
this.router.navigateByUrl('/home');
return;
}
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.libraryId = parseInt(routeId, 10);
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => {
this.libraryName = names[this.libraryId];
this.titleService.setTitle('Kavita - ' + this.libraryName);
this.cdRef.markForCheck();
});
this.libraryService.getJumpBar(this.libraryId).subscribe(barDetails => {
this.jumpKeys = barDetails;
this.cdRef.markForCheck();
});
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
const defaultStmt = {field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal};
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(defaultStmt);
}
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck!.statements.push(defaultStmt);
this.filterSettings.presetsV2 = this.filter;
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
this.cdRef.markForCheck();
});
}
ngOnInit(): void {
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event.event === EVENTS.SeriesAdded) {
const seriesAdded = event.payload as SeriesAddedEvent;
if (seriesAdded.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
this.loadPageSource.next(true);
return;
}
this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => {
if (this.series.filter(sObj => s.id === sObj.id).length > 0) return;
this.series = [...this.series, s].sort((s1: Series, s2: Series) => {
if (s1.sortName < s2.sortName) return -1;
if (s1.sortName > s2.sortName) return 1;
return 0;
});
this.pagination.totalItems++;
this.cdRef.markForCheck();
this.refresh.emit();
});
} else if (event.event === EVENTS.SeriesRemoved) {
const seriesRemoved = event.payload as SeriesRemovedEvent;
if (seriesRemoved.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
this.loadPageSource.next(true);
return;
}
this.series = this.series.filter(s => s.id != seriesRemoved.seriesId);
this.pagination.totalItems--;
this.cdRef.markForCheck();
this.refresh.emit();
}
});
}
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = true;
}
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = false;
}
}
async handleAction(action: ActionItem<Library>, library: Library) {
let lib: Partial<Library> = library;
if (library === undefined) {
this.libraryService.getLibrary(this.libraryId).subscribe(async library => {
switch (action.action) {
case(Action.Scan):
await this.actionService.scanLibrary(library);
break;
case(Action.RefreshMetadata):
await this.actionService.refreshLibraryMetadata(library);
break;
case(Action.GenerateColorScape):
await this.actionService.refreshLibraryMetadata(library, undefined, false);
break;
case (Action.Delete):
await this.actionService.deleteLibrary(library, () => {
this.loadPageSource.next(true);
});
break;
case (Action.AnalyzeFiles):
await this.actionService.analyzeFiles(library);
break;
case(Action.Edit):
this.actionService.editLibrary(library);
break;
default:
break;
}
});
return
}
switch (action.action) {
case(Action.Scan):
await this.actionService.scanLibrary(lib);
break;
case(Action.RefreshMetadata):
await this.actionService.refreshLibraryMetadata(lib);
break;
case(Action.GenerateColorScape):
await this.actionService.refreshLibraryMetadata(lib, undefined, false);
break;
case(Action.Edit):
this.actionService.editLibrary(lib);
break;
default:
break;
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, undefined);
}
}
updateFilter(data: FilterEvent<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (data.isFirst) {
this.loadPageSource.next(true);
return;
}
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
this.loadPageSource.next(true);
});
}
loadPage() {
this.loadingSeries = true;
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
this.cdRef.markForCheck();
this.seriesService.getSeriesForLibraryV2(undefined, undefined, this.filter)
.subscribe(series => {
this.series = series.result;
this.pagination = series.pagination;
this.loadingSeries = false;
this.cdRef.markForCheck();
});
}
trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`;
}