import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, DestroyRef, EventEmitter, HostListener, inject, Input, OnInit, Output, TemplateRef } 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 {UserCollection} 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, ActionableEntity, 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 {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {DecimalPipe, NgTemplateOutlet} from "@angular/common"; import {RouterLink, RouterLinkActive} from "@angular/router"; import {TranslocoModule} from "@jsverse/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 {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {BrowsePerson} from "../../_models/person/browse-person"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson; @Component({ selector: 'app-card-item', imports: [ ImageComponent, NgbProgressbar, DownloadIndicatorComponent, FormsModule, NgbTooltip, CardActionablesComponent, SentenceCasePipe, RouterLink, TranslocoModule, RouterLinkActive, PromotedIconComponent, SeriesFormatComponent, DecimalPipe, NgTemplateOutlet, CompactNumberPipe ], 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 = ''; /** * Any actions to perform on the card */ @Input() actions: ActionItem[] = []; /** * 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!: CardEntity; /** * An optional entity to be used in the action callback */ @Input() actionEntity: ActionableEntity | null = null; /** * 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; /** * Show a read button. Emits on (readClicked) */ @Input() showReadButton: boolean = false; /** * If overlay is enabled, should the text be centered or not */ @Input() centerOverlay = false; /** * Will generate a button to instantly read */ @Input() hasReadButton = false; /** * A method that if defined will return the url */ @Input() linkUrl?: string; /** * Show the format of the series */ @Input() showFormat: boolean = true; /** * Event emitted when item is clicked */ @Output() clicked = new EventEmitter(); /** * When the card is selected. */ @Output() selection = new EventEmitter(); @Output() readClicked = new EventEmitter(); @ContentChild('subtitle') subtitleTemplate!: TemplateRef; /** * 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 | 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 chapter = this.utilityService.asChapter(this.entity); const chapterTitle = chapter.titleName; if (chapterTitle === '' || chapterTitle === null || chapterTitle === undefined) { const volumeTitle = chapter.volumeTitle if (volumeTitle === '' || volumeTitle === null || volumeTitle === undefined) { this.tooltipTitle = (this.title).trim(); } else { this.tooltipTitle = (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 = ` // //
${tokens[0]}
${tokens[1]}
`; // // todo: figure out where this caller is this.centerOverlay = true; if (nextDate.expectedDate) { const utcPipe = new UtcToLocalTimePipe(); this.title = '~ ' + utcPipe.transform(nextDate.expectedDate, 'shortDate'); } this.cdRef.markForCheck(); } else { this.tooltipTitle = this.title; } 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) => { return this.downloadService.mapToEntityType(events, this.entity); })); } @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); } performAction(action: ActionItem) { 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 UserCollection; 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'); // } } // this.actions = this.actions.filter(a => { // if (!a.isAllowed) return true; // return a.isAllowed(a, this.entity); // }); } clickRead(event: any) { event.stopPropagation(); if (this.bulkSelectionService.hasSelections()) return; this.readClicked.emit(this.entity); } }