Kavita/UI/Web/src/app/cards/card-item/card-item.component.ts
2024-01-02 18:53:10 -06:00

387 lines
14 KiB
TypeScript

import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
EventEmitter,
HostListener,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import { RecentlyAddedItem } from 'src/app/_models/recently-added-item';
import { Series } from 'src/app/_models/series';
import { User } from 'src/app/_models/user';
import { Volume } from 'src/app/_models/volume';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { BulkSelectionService } from '../bulk-selection.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ImageComponent} from "../../shared/image/image.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {FormsModule} from "@angular/forms";
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {CommonModule} from "@angular/common";
import {RouterLink, RouterLinkActive} from "@angular/router";
import {TranslocoModule} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
@Component({
selector: 'app-card-item',
standalone: true,
imports: [
CommonModule,
ImageComponent,
NgbProgressbar,
DownloadIndicatorComponent,
FormsModule,
NgbTooltip,
MangaFormatPipe,
MangaFormatIconPipe,
CardActionablesComponent,
SentenceCasePipe,
RouterLink,
TranslocoModule,
SafeHtmlPipe,
RouterLinkActive
],
templateUrl: './card-item.component.html',
styleUrls: ['./card-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardItemComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly libraryService = inject(LibraryService);
private readonly downloadService = inject(DownloadService);
private readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly accountService = inject(AccountService);
private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService);
protected readonly MangaFormat = MangaFormat;
/**
* Card item url. Will internally handle error and missing covers
*/
@Input() imageUrl = '';
/**
* Name of the card
*/
@Input() title = '';
/**
* Shows below the title. Defaults to not visible
*/
@Input() subtitle = '';
/**
* Any actions to perform on the card
*/
@Input() actions: ActionItem<any>[] = [];
/**
* Pages Read
*/
@Input() read = 0;
/**
* Total Pages
*/
@Input() total = 0;
/**
* Suppress library link
*/
@Input() suppressLibraryLink = false;
/**
* This is the entity we are representing. It will be returned if an action is executed.
*/
@Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
/**
* If the entity is selected or not.
*/
@Input() selected: boolean = false;
/**
* If the entity should show selection code
*/
@Input() allowSelection: boolean = false;
/**
* This will suppress the "cannot read archive warning" when total pages is 0
*/
@Input() suppressArchiveWarning: boolean = false;
/**
* The number of updates/items within the card. If less than 2, will not be shown.
*/
@Input() count: number = 0;
/**
* Additional information to show on the overlay area. Will always render.
*/
@Input() overlayInformation: string = '';
/**
* If overlay is enabled, should the text be centered or not
*/
@Input() centerOverlay = false;
/**
* Event emitted when item is clicked
*/
@Output() clicked = new EventEmitter<string>();
/**
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
/**
* Library name item belongs to
*/
libraryName: string | undefined = undefined;
libraryId: number | undefined = undefined;
/**
* Format of the entity (only applies to Series)
*/
format: MangaFormat = MangaFormat.UNKNOWN;
tooltipTitle: string = this.title;
/**
* This is the download we get from download service.
*/
download$: Observable<DownloadEvent | null> | null = null;
/**
* Handles touch events for selection on mobile devices
*/
prevTouchTime: number = 0;
/**
* Handles touch events for selection on mobile devices to ensure you aren't touch scrolling
*/
prevOffset: number = 0;
selectionInProgress: boolean = false;
private user: User | undefined;
ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
this.suppressArchiveWarning = true;
this.cdRef.markForCheck();
}
if (!this.suppressLibraryLink) {
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
this.libraryId = (this.entity as Series).libraryId;
this.cdRef.markForCheck();
}
if (this.libraryId !== undefined && this.libraryId > 0) {
this.libraryService.getLibraryName(this.libraryId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(name => {
this.libraryName = name;
this.cdRef.markForCheck();
});
}
}
this.format = (this.entity as Series).format;
if (this.utilityService.isChapter(this.entity)) {
const chapterTitle = this.utilityService.asChapter(this.entity).titleName;
if (chapterTitle === '' || chapterTitle === null || chapterTitle === undefined) {
const volumeTitle = this.utilityService.asChapter(this.entity).volumeTitle
if (volumeTitle === '' || volumeTitle === null || volumeTitle === undefined) {
this.tooltipTitle = (this.title).trim();
} else {
this.tooltipTitle = (this.utilityService.asChapter(this.entity).volumeTitle + ' ' + this.title).trim();
}
} else {
this.tooltipTitle = chapterTitle;
}
} else if (this.utilityService.isVolume(this.entity)) {
const vol = this.utilityService.asVolume(this.entity);
if (vol.chapters !== undefined && vol.chapters.length > 0) {
this.tooltipTitle = vol.chapters[0].titleName;
}
if (this.tooltipTitle === '') {
this.tooltipTitle = vol.name;
}
} else if (this.utilityService.isSeries(this.entity)) {
this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name);
} else if (this.entity.hasOwnProperty('expectedDate')) {
this.suppressArchiveWarning = true;
this.imageUrl = '';
const nextDate = (this.entity as NextExpectedChapter);
const tokens = nextDate.title.split(':');
this.overlayInformation = `
<i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
<div>${tokens[0]}</div><div>${tokens[1]}</div>`;
this.centerOverlay = true;
if (nextDate.expectedDate) {
const utcPipe = new UtcToLocalTimePipe();
this.title = '~ ' + utcPipe.transform(nextDate.expectedDate, 'shortDate');
}
this.cdRef.markForCheck();
}
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user;
});
this.messageHub.messages$.pipe(filter(event => event.event === EVENTS.UserProgressUpdate),
map(evt => evt.payload as UserProgressUpdateEvent), takeUntilDestroyed(this.destroyRef)).subscribe(updateEvent => {
if (this.user === undefined || this.user.username !== updateEvent.username) return;
if (this.utilityService.isChapter(this.entity) && updateEvent.chapterId !== this.entity.id) return;
if (this.utilityService.isVolume(this.entity) && updateEvent.volumeId !== this.entity.id) return;
if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return;
// For volume or Series, we can't just take the event
if (this.utilityService.isChapter(this.entity)) {
const c = this.utilityService.asChapter(this.entity);
c.pagesRead = updateEvent.pagesRead;
this.read = updateEvent.pagesRead;
}
if (this.utilityService.isVolume(this.entity) || this.utilityService.isSeries(this.entity)) {
if (this.utilityService.isVolume(this.entity)) {
const v = this.utilityService.asVolume(this.entity);
let sum = 0;
const chapters = v.chapters.filter(c => c.volumeId === updateEvent.volumeId);
chapters.forEach(chapter => {
chapter.pagesRead = updateEvent.pagesRead;
sum += chapter.pagesRead;
});
v.pagesRead = sum;
this.read = sum;
} else {
return;
}
}
this.cdRef.detectChanges();
});
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
if(this.utilityService.isSeries(this.entity)) return events.find(e => e.entityType === 'series' && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null;
if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null;
if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null;
// Is PageBookmark[]
if(this.entity.hasOwnProperty('length')) return events.find(e => e.entityType === 'bookmark' && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null;
return null;
}));
}
@HostListener('touchmove', ['$event'])
onTouchMove(event: TouchEvent) {
if (!this.allowSelection) return;
this.selectionInProgress = false;
this.cdRef.markForCheck();
}
@HostListener('touchstart', ['$event'])
onTouchStart(event: TouchEvent) {
if (!this.allowSelection) return;
this.prevTouchTime = event.timeStamp;
this.prevOffset = this.scrollService.scrollPosition;
this.selectionInProgress = true;
}
@HostListener('touchend', ['$event'])
onTouchEnd(event: TouchEvent) {
if (!this.allowSelection) return;
const delta = event.timeStamp - this.prevTouchTime;
const verticalOffset = this.scrollService.scrollPosition;
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset) && this.selectionInProgress) {
this.handleSelection();
event.stopPropagation();
event.preventDefault();
}
this.prevTouchTime = 0;
this.selectionInProgress = false;
}
handleClick(event?: any) {
if (this.bulkSelectionService.hasSelections()) {
this.handleSelection();
return;
}
this.clicked.emit(this.title);
}
preventClick(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.download('volume', volume);
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
this.downloadService.download('chapter', chapter);
} else if (this.utilityService.isSeries(this.entity)) {
const series = this.utilityService.asSeries(this.entity);
this.downloadService.download('series', series);
}
return; // Don't propagate the download from a card
}
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
isPromoted() {
const tag = this.entity as CollectionTag;
return tag.hasOwnProperty('promoted') && tag.promoted;
}
handleSelection(event?: any) {
if (event) {
event.stopPropagation();
}
this.selection.emit(this.selected);
this.cdRef.detectChanges();
}
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
if (this.utilityService.isChapter(this.entity)) {
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.entity as Chapter);
} else if (this.utilityService.isVolume(this.entity)) {
const vol = this.utilityService.asVolume(this.entity);
this.actions = this.actionFactoryService.filterSendToAction(this.actions, vol.chapters[0]);
} else if (this.utilityService.isSeries(this.entity)) {
const series = (this.entity as Series);
// if (series.format === MangaFormat.EPUB || series.format === MangaFormat.PDF) {
// this.actions = this.actions.filter(a => a.title !== 'Send To');
// }
}
}
}