Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-08-24 19:23:57 -05:00 committed by GitHub
parent dbc4f35107
commit c93af3e56f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 1989 additions and 2877 deletions

View file

@ -629,7 +629,7 @@ export class EditSeriesModalComponent implements OnInit {
await this.actionService.refreshSeriesMetadata(this.series);
break;
case Action.GenerateColorScape:
await this.actionService.refreshSeriesMetadata(this.series, undefined, false);
await this.actionService.refreshSeriesMetadata(this.series, undefined, false, true);
break;
case Action.AnalyzeFiles:
this.actionService.analyzeFilesForSeries(this.series);

View file

@ -1,121 +0,0 @@
<ng-container *transloco="let t; read: 'card-detail-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
<span class="modal-title" id="modal-basic-title">
<app-entity-title [libraryType]="libraryType" [entity]="data" [seriesName]="parentName"></app-entity-title>
</span>
</h5>
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body pb-3">
<div class="d-flex">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="vertical" style="max-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>{{t(tabs[TabID.General].title)}}</a>
<ng-template ngbNavContent>
<div class="container-fluid" style="overflow: auto">
<div class="row g-0">
<div class="d-none d-md-block col-md-2 col-lg-1">
<app-image class="me-2" width="74px" [imageUrl]="coverImageUrl"></app-image>
</div>
<div class="col-md-10 col-lg-11">
<ng-container *ngIf="summary.length > 0; else noSummary">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</ng-container>
<ng-template #noSummary>
{{t('no-summary')}}
</ng-template>
</div>
</div>
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Metadata]">
<a ngbNavLink>{{t(tabs[TabID.Metadata].title)}}</a>
<ng-template ngbNavContent>
<app-chapter-metadata-detail [chapter]="chapter"></app-chapter-metadata-detail>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Progress]">
<a ngbNavLink>{{t(tabs[TabID.Progress].title)}}</a>
<ng-template ngbNavContent>
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(accountService.isAdmin$ | async) === false">
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateCoverImageIndex($event)"
(selectedBase64Url)="applyCoverImage($event)" [showReset]="chapter.coverImageLocked"
(resetClicked)="resetCoverImage()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(accountService.isAdmin$ | async) === false">
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
<ng-template ngbNavContent>
@if (!utilityService.isChapter(data)) {
<h4>{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
}
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" [title]="t('read')">
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span>
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.minNumber !== LooseLeafOrSpecialNumber; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
</span>
</span>
<ng-template #specialHeader>{{t('files')}}</ng-template>
</h5>
<ul class="list-group">
@for (file of chapter.files; track file.id) {
<li class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
{{t('pages')}} {{file.pages | number:''}}
</div>
@if (data.hasOwnProperty('created')) {
<div class="col">
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
</div>
}
<div class="col">
{{t('size')}} {{file.bytes | bytes}}
</div>
</div>
</li>
}
</ul>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
</div>
</ng-container>

View file

@ -1,20 +0,0 @@
.hide-if-empty:empty {
display: none !important;
}
.offcanvas-body {
overflow: auto;
}
.offcanvas-header {
padding: 1rem 1rem 0;
}
.tab-content {
overflow: auto;
height: calc(40vh - (46px + 1rem)); // drawer height - offcanvas heading height
}
.h6 {
font-weight: 600;
}

View file

@ -1,278 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import { Router } from '@angular/router';
import {
NgbActiveOffcanvas,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Observable, of } from 'rxjs';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import {Chapter, LooseLeafOrDefaultNumber} from 'src/app/_models/chapter';
import { Device } from 'src/app/_models/device/device';
import { LibraryType } from 'src/app/_models/library/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Volume } from 'src/app/_models/volume';
import { AccountService } from 'src/app/_services/account.service';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { UploadService } from 'src/app/_services/upload.service';
import {CommonModule} from "@angular/common";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {ImageComponent} from "../../shared/image/image.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {CoverImageChooserComponent} from "../cover-image-chooser/cover-image-chooser.component";
import {ChapterMetadataDetailComponent} from "../chapter-metadata-detail/chapter-metadata-detail.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
import {CarouselTabsComponent} from "../../carousel/_components/carousel-tabs/carousel-tabs.component";
import {CarouselTabComponent} from "../../carousel/_components/carousel-tab/carousel-tab.component";
enum TabID {
General = 0,
Metadata = 1,
Cover = 2,
Progress = 3,
Files = 4
}
@Component({
selector: 'app-card-detail-drawer',
standalone: true,
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent, CarouselTabsComponent, CarouselTabComponent],
templateUrl: './card-detail-drawer.component.html',
styleUrls: ['./card-detail-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardDetailDrawerComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly router = inject(Router);
private readonly libraryService = inject(LibraryService);
private readonly readerService = inject(ReaderService);
protected readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly downloadService = inject(DownloadService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
protected readonly LibraryType = LibraryType;
protected readonly TabID = TabID;
protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber;
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input({required: true}) data!: Volume | Chapter;
/**
* If this is a volume, this will be first chapter for said volume.
*/
chapter!: Chapter;
isChapter = false;
chapters: Chapter[] = [];
imageUrls: Array<string> = [];
/**
* Cover image for the entity
*/
coverImageUrl!: string;
isAdmin$: Observable<boolean> = of(false);
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga;
tabs = [
{title: 'general-tab', disabled: false},
{title: 'metadata-tab', disabled: false},
{title: 'cover-tab', disabled: false},
{title: 'progress-tab', disabled: false},
{title: 'info-tab', disabled: false}
];
active = this.tabs[0];
summary: string = '';
downloadInProgress: boolean = false;
ngOnInit(): void {
this.imageUrls = this.chapters.map(c => this.imageService.getChapterCoverImage(c.id));
this.isChapter = this.utilityService.isChapter(this.data);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
if (this.isChapter) {
this.coverImageUrl = this.imageService.getChapterCoverImage(this.data.id);
this.summary = this.utilityService.asChapter(this.data).summary || '';
this.chapters.push(this.data as Chapter);
} else {
this.coverImageUrl = this.imageService.getVolumeCoverImage(this.data.id);
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
this.chapters.push(...(this.data as Volume).chapters);
}
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
.filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'read', description: '', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
if (this.isChapter) {
const chapter = this.utilityService.asChapter(this.data);
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
} else {
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, this.chapters[0]);
}
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
this.cdRef.markForCheck();
});
const collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
this.imageUrls = this.chapters.map(c => this.imageService.getChapterCoverImage(c.id));
this.cdRef.markForCheck();
}
close() {
this.activeOffcanvas.close();
}
formatChapterNumber(chapter: Chapter) {
if (chapter.minNumber === LooseLeafOrDefaultNumber) {
return '1';
}
return chapter.range + '';
}
performAction(action: ActionItem<any>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action, chapter);
}
}
applyCoverImage(coverUrl: string) {
this.uploadService.updateChapterCoverImage(this.chapter.id, coverUrl).subscribe(() => {});
}
updateCoverImageIndex(selectedIndex: number) {
if (selectedIndex <= 0) return;
this.applyCoverImage(this.imageUrls[selectedIndex]);
}
resetCoverImage() {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info(translate('toasts.regen-cover'));
});
}
markChapterAsRead(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
markChapterAsUnread(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.AddToReadingList):
this.actionService.addChapterToReadingList(chapter, this.seriesId);
break;
case (Action.IncognitoRead):
this.readChapter(chapter, true);
break;
case (Action.Download):
this.download(chapter);
break;
case (Action.Read):
this.readChapter(chapter, false);
break;
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([chapter.id], device);
break;
}
default:
break;
}
}
readChapter(chapter: Chapter, incognito: boolean = false) {
if (chapter.pages === 0) {
this.toastr.error(translate('toasts.no-pages'));
return;
}
const params = this.readerService.getQueryParamsObject(incognito, false);
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, chapter.id, chapter.files[0].format), {queryParams: params});
this.close();
}
download(chapter: Chapter) {
if (this.downloadInProgress) {
this.toastr.info(translate('toasts.download-in-progress'));
return;
}
this.downloadInProgress = true;
this.cdRef.markForCheck();
this.downloadService.download('chapter', chapter, (d) => {
if (d) return;
this.downloadInProgress = false;
this.cdRef.markForCheck();
});
}
}

View file

@ -32,6 +32,8 @@
overflow-y: auto;
overflow-x: hidden;
align-items: start;
}
@media (max-width: 576px) {
@ -92,13 +94,33 @@
.virtual-scroller, virtual-scroller {
width: 100%;
height: calc(var(--vh) * 100 - 173px);
height: calc(var(--vh) * 100 - 143px);
mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
overflow: auto;
}
virtual-scroller.empty {
display: none;
}
.vertical.selfScroll {
&::-webkit-scrollbar {
width: inherit;
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /*makes it invisible when not hovering*/
}
&:hover {
&::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/
}
}
}
h2 {
display: inline-block;
word-break: break-all;

View file

@ -44,14 +44,19 @@
}
<div class="card-overlay"></div>
@if (overlayInformation | safeHtml; as info) {
@if (info) {
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}">
<div class="position-relative">
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
</div>
@if (showReadButton) {
<div class="series overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;">
<span (click)="clickRead($event)">
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</span>
</div>
}
</div>
}
</div>
<div class="card-body meta-title">

View file

@ -134,13 +134,17 @@ export class CardItemComponent implements OnInit {
*/
@Input() count: number = 0;
/**
* Additional information to show on the overlay area. Will always render.
* Show a read button. Emits on (readClicked)
*/
@Input() overlayInformation: string = '';
@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;
/**
* Event emitted when item is clicked
*/
@ -149,6 +153,7 @@ export class CardItemComponent implements OnInit {
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
@Output() readClicked = new EventEmitter<Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
/**
* Library name item belongs to
@ -229,9 +234,10 @@ export class CardItemComponent implements OnInit {
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.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>`;
// // todo: figure out where this caller is
this.centerOverlay = true;
if (nextDate.expectedDate) {
@ -387,4 +393,11 @@ export class CardItemComponent implements OnInit {
// return a.isAllowed(a, this.entity);
// });
}
clickRead(event: any) {
event.stopPropagation();
if (this.bulkSelectionService.hasSelections()) return;
this.readClicked.emit(this.entity);
}
}

View file

@ -1,132 +0,0 @@
<ng-container *transloco="let t; read: 'chapter-metadata-detail'">
<ng-container *ngIf="chapter !== undefined">
<span *ngIf="chapter.writers.length === 0 && chapter.coverArtists.length === 0
&& chapter.pencillers.length === 0 && chapter.inkers.length === 0
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
&& chapter.editors.length === 0 && chapter.publishers.length === 0
&& chapter.characters.length === 0 && chapter.translators.length === 0
&& chapter.imprints.length === 0 && chapter.locations.length === 0
&& chapter.teams.length === 0">
{{t('no-data')}}
</span>
<div class="container-flex row row-cols-auto row-cols-lg-5 g-2 g-lg-3 me-0 mt-2">
<div class="col-auto mt-2" *ngIf="chapter.writers && chapter.writers.length > 0">
<h6>{{t('writers-title')}}</h6>
<app-badge-expander [items]="chapter.writers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.coverArtists && chapter.coverArtists.length > 0">
<h6>{{t('cover-artists-title')}}</h6>
<app-badge-expander [items]="chapter.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.pencillers && chapter.pencillers.length > 0">
<h6>{{t('pencillers-title')}}</h6>
<app-badge-expander [items]="chapter.pencillers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.inkers && chapter.inkers.length > 0">
<h6>{{t('inkers-title')}}</h6>
<app-badge-expander [items]="chapter.inkers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.colorists && chapter.colorists.length > 0">
<h6>{{t('colorists-title')}}</h6>
<app-badge-expander [items]="chapter.colorists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.letterers && chapter.letterers.length > 0">
<h6>{{t('letterers-title')}}</h6>
<app-badge-expander [items]="chapter.letterers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.editors && chapter.editors.length > 0">
<h6>{{t('editors-title')}}</h6>
<app-badge-expander [items]="chapter.editors">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.publishers && chapter.publishers.length > 0">
<h6>{{t('publishers-title')}}</h6>
<app-badge-expander [items]="chapter.publishers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.imprints && chapter.imprints.length > 0">
<h6>{{t('imprints-title')}}</h6>
<app-badge-expander [items]="chapter.imprints">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.characters && chapter.characters.length > 0">
<h6>{{t('characters-title')}}</h6>
<app-badge-expander [items]="chapter.characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.teams && chapter.teams.length > 0">
<h6>{{t('teams-title')}}</h6>
<app-badge-expander [items]="chapter.teams">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.locations && chapter.locations.length > 0">
<h6>{{t('locations-title')}}</h6>
<app-badge-expander [items]="chapter.locations">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.translators && chapter.translators.length > 0">
<h6>{{t('translators-title')}}</h6>
<app-badge-expander [items]="chapter.translators">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
</div>
</ng-container>
</ng-container>

View file

@ -1,18 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import {CommonModule} from "@angular/common";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {Chapter} from "../../_models/chapter";
@Component({
selector: 'app-chapter-metadata-detail',
standalone: true,
imports: [CommonModule, BadgeExpanderComponent, PersonBadgeComponent, TranslocoDirective],
templateUrl: './chapter-metadata-detail.component.html',
styleUrls: ['./chapter-metadata-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChapterMetadataDetailComponent {
@Input() chapter: Chapter | undefined;
}

View file

@ -1,130 +0,0 @@
<ng-container *transloco="let t; read: 'entity-info-cards'">
<div class="mt-3 mb-3">
<div class="row g-0" *ngIf="chapter ">
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
<app-metadata-detail [tags]="chapter.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
<ng-template #titleTemplate let-item>{{item.title}}</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="chapter.characters" [libraryId]="libraryId" [queryParam]="FilterField.Characters" heading="Characters">
<ng-template #titleTemplate let-item>{{item.name}}</ng-template>
</app-metadata-detail>
</div>
<div class="row g-0">
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('release-date-tooltip')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-date-title')">
{{chapter.releaseDate | date:'shortDate' | defaultDate}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.ageRating !== AgeRating.Unknown">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="false" fontClasses="fas fa-eye" [title]="t('age-rating-title')">
{{chapter.ageRating | ageRating}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: totalPages | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: totalWordCount | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('read-time-title')" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} {{readingTime.minHours > 1 ? t('hours') : t('hour')}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && chapter.createdUtc && chapter.createdUtc !== '' && (chapter.createdUtc | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('date-added-title')" [clickable]="false" fontClasses="fa-solid fa-file-import" [title]="t('date-added-title')">
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && size > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('size-title')" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" [title]="t('size-title')">
{{size | bytes}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('id-title')" [clickable]="false" fontClasses="fa-solid fa-fingerprint" [title]="t('id-title')">
{{entity.id}}
</app-icon-and-title>
</div>
<ng-container *ngIf="WebLinks.length > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.isbn.length > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('isbn-title')" [clickable]="false" fontClasses="fa-solid fa-barcode" [title]="t('isbn-title')">
{{chapter.isbn}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="(chapter.lastReadingProgress | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [ngbTooltip]="chapter.lastReadingProgress | date: 'medium'">
{{chapter.lastReadingProgress | date: 'shortDate'}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="isChapter">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('sort-order-title')" [clickable]="false" fontClasses="fa-solid fa-arrow-down-1-9" [title]="t('sort-order-title')">
{{chapter.sortOrder}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -1,116 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnInit,
inject,
} from '@angular/core';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
import { MangaFormat } from 'src/app/_models/manga-format';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { Volume } from 'src/app/_models/volume';
import { SeriesService } from 'src/app/_services/series.service';
import { ImageService } from 'src/app/_services/image.service';
import {CommonModule} from "@angular/common";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
import {TranslocoModule} from "@jsverse/transloco";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ImageComponent} from "../../shared/image/image.component";
@Component({
selector: 'app-entity-info-cards',
standalone: true,
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe,
AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule,
UtcToLocalTimePipe, ImageComponent],
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EntityInfoCardsComponent implements OnInit {
protected readonly AgeRating = AgeRating;
protected readonly MangaFormat = MangaFormat;
protected readonly FilterField = FilterField;
public readonly imageService = inject(ImageService);
@Input({required: true}) entity!: Volume | Chapter;
@Input({required: true}) libraryId!: number;
/**
* Hide more system based fields, like id or Date Added
*/
@Input() showExtendedProperties: boolean = true;
isChapter = false;
chapter!: Chapter;
ageRating!: string;
totalPages: number = 0;
totalWordCount: number = 0;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
size: number = 0;
get WebLinks() {
if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(',');
}
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
this.chapter = this.utilityService.isChapter(this.entity) ? (this.entity as Chapter) : (this.entity as Volume).chapters[0];
if (this.isChapter) {
this.size = this.utilityService.asChapter(this.entity).files.reduce((sum, v) => sum + v.bytes, 0);
} else {
this.size = this.utilityService.asVolume(this.entity).chapters.reduce((sum1, chapter) => {
return sum1 + chapter.files.reduce((sum2, file) => {
return sum2 + file.bytes;
}, 0);
}, 0);
}
this.totalPages = this.chapter.pages;
if (!this.isChapter) {
this.totalPages = this.utilityService.asVolume(this.entity).pages;
}
this.totalWordCount = this.chapter.wordCount;
if (!this.isChapter) {
this.totalWordCount = this.utilityService.asVolume(this.entity).chapters.map(c => c.wordCount).reduce((sum, d) => sum + d);
}
if (this.isChapter) {
this.readingTime.minHours = this.chapter.minHoursToRead;
this.readingTime.maxHours = this.chapter.maxHoursToRead;
this.readingTime.avgHours = this.chapter.avgHoursToRead;
} else {
const vol = this.utilityService.asVolume(this.entity);
this.readingTime.minHours = vol.minHoursToRead;
this.readingTime.maxHours = vol.maxHoursToRead;
this.readingTime.avgHours = vol.avgHoursToRead;
}
this.cdRef.markForCheck();
}
}

View file

@ -2,9 +2,12 @@
@switch (libraryType) {
@case (LibraryType.Comic) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
@ -14,9 +17,12 @@
@case (LibraryType.ComicVine) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
@ -26,12 +32,15 @@
@case (LibraryType.Manga) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('chapter') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
}
}

View file

@ -28,12 +28,15 @@ export class EntityTitleComponent implements OnInit {
* Library type for which the entity belongs
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
@Input() seriesName: string = '';
@Input({required: true}) entity!: Volume | Chapter;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* When generating the title, should this prepend 'Chapter number' before the Chapter titlename
*/
@Input() includeChapter: boolean = false;
/**
* When a titleName (aka a title) is available on the entity, show it over Volume X Chapter Y
*/

View file

@ -1,17 +0,0 @@
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-0">
<ng-content select="[title]"></ng-content>
</h5>
@if (summary && summary.length > 0) {
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
}
</div>
</div>
</div>

View file

@ -1,6 +0,0 @@
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}

View file

@ -1,31 +0,0 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../shared/image/image.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
@Component({
selector: 'app-external-list-item',
standalone: true,
imports: [CommonModule, ImageComponent, NgbProgressbar, NgbTooltip, ReadMoreComponent],
templateUrl: './external-list-item.component.html',
styleUrls: ['./external-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExternalListItemComponent {
/**
* Image to show
*/
@Input() imageUrl: string = '';
/**
* Size of the Image Height. Defaults to 232.91px.
*/
@Input() imageHeight: string = '232.91px';
/**
* Size of the Image Width Defaults to 160px.
*/
@Input() imageWidth: string = '160px';
@Input() summary: string | null = '';
}

View file

@ -1,40 +0,0 @@
<ng-container *transloco="let t; read: 'list-item'">
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
<span class="download">
<app-download-indicator [download$]="download$"></app-download-indicator>
</span>
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages"
ngbTooltip="{{(pagesRead / totalPages) | number:'1.0-1'}}% Read">
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
</div>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-0">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="seriesName" iconClass="fa-ellipsis-v"></app-card-actionables>
<ng-content select="[title]"></ng-content>
<button class="btn btn-primary float-end" (click)="read.emit()">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">{{t('read')}}</span>
</button>
</h5>
<h6 class="text-muted" [ngClass]="{'subtitle-with-actionables' : actions.length > 0}" *ngIf="Title !== '' && showTitle">{{Title}}</h6>
<ng-container *ngIf="summary.length > 0">
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [blur]="pagesRead === 0 && blur" [maxLength]="250"></app-read-more>
</div>
</ng-container>
<div class="ps-2 d-none d-md-inline-block" style="width: 100%">
<app-entity-info-cards [entity]="entity" [libraryId]="libraryId" [showExtendedProperties]="ShowExtended"></app-entity-info-cards>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -1,40 +0,0 @@
// with summary and cards, we have a height of 220px, we might want to default to 220px and let it grow from there to help with virtualization
.download {
width: 80px;
height: 80px;
position: absolute;
top: 55px;
left: 20px;
}
.progress-banner {
height: 5px;
.progress {
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}
.not-read-badge {
position: absolute;
top: 8px;
left: 110px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent;
}
.subtitle-with-actionables {
font-size: 0.75rem;
word-break: break-all;
}

View file

@ -1,161 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { map, Observable } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library/library';
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {CommonModule} from "@angular/common";
import {ImageComponent} from "../../shared/image/image.component";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({
selector: 'app-list-item',
standalone: true,
imports: [CommonModule, ReadMoreComponent, ImageComponent, DownloadIndicatorComponent, EntityInfoCardsComponent, CardActionablesComponent, NgbProgressbar, NgbTooltip, TranslocoDirective],
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListItemComponent implements OnInit {
/**
* Volume or Chapter to render
*/
@Input({required: true}) entity!: Volume | Chapter;
@Input({required: true}) libraryId!: number;
/**
* Image to show
*/
@Input() imageUrl: string = '';
/**
* Actions to show
*/
@Input() actions: ActionItem<any>[] = []; // Volume | Chapter
/**
* Library type to help with formatting title
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
/**
* Name of the Series to show under the title
*/
@Input() seriesName: string = '';
/**
* Size of the Image Height. Defaults to 232.91px.
*/
@Input() imageHeight: string = '232.91px';
/**
* Size of the Image Width Defaults to 160px.
*/
@Input() imageWidth: string = '160px';
@Input() seriesLink: string = '';
@Input() pagesRead: number = 0;
@Input() totalPages: number = 0;
@Input() relation: RelationKind | undefined = undefined;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* Show's the title if available on entity
*/
@Input() showTitle: boolean = true;
/**
* Blur the summary for the list item
*/
@Input() blur: boolean = false;
@Output() read: EventEmitter<void> = new EventEmitter<void>();
private readonly destroyRef = inject(DestroyRef);
actionInProgress: boolean = false;
summary: string = '';
isChapter: boolean = false;
download$: Observable<DownloadEvent | null> | null = null;
downloadInProgress: boolean = false;
get Title() {
if (this.isChapter) return (this.entity as Chapter).titleName;
return '';
}
get ShowExtended() {
return this.utilityService.getActiveBreakpoint() === Breakpoint.Desktop;
}
protected readonly Breakpoint = Breakpoint;
constructor(public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
this.summary = this.utilityService.asChapter(this.entity).summary || '';
} else {
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
}
this.cdRef.markForCheck();
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
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;
return null;
}));
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
if (this.downloadInProgress) {
this.toastr.info(translate('toasts.download-in-progress'));
return;
}
const statusUpdate = (d: Download | undefined) => {
if (d) return;
this.downloadInProgress = false;
};
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.download('volume', volume, statusUpdate);
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
this.downloadService.download('chapter', chapter, statusUpdate);
}
return; // Don't propagate the download from a card
}
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
}

View file

@ -6,21 +6,22 @@
<div class="card-overlay"></div>
</div>
<ng-container *ngIf="entity.title | safeHtml as info">
<div class="card-body meta-title" *ngIf="info !== ''">
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
@if (entity.title | safeHtml; as info) {
@if (info !== '') {
<div class="card-body meta-title">
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div>
<span [innerHTML]="info"></span>
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div>
<span [innerHTML]="info"></span>
</div>
</div>
</div>
</ng-container>
}
}
<div class="card-title-container">
<span class="card-title" tabindex="0">
{{title}}
</span>
</div>
<div class="card-title-container">
<span class="card-title" tabindex="0">
{{title}}
</span>
</div>
</div>

View file

@ -1,5 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../shared/image/image.component";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -9,7 +8,7 @@ import {translate} from "@jsverse/transloco";
@Component({
selector: 'app-next-expected-card',
standalone: true,
imports: [CommonModule, ImageComponent, SafeHtmlPipe],
imports: [ImageComponent, SafeHtmlPipe],
templateUrl: './next-expected-card.component.html',
styleUrl: './next-expected-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -62,8 +62,9 @@
</div>
<div class="card-title-container">
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}" tabindex="0">
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}">
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}">
<app-series-format [format]="series.format"></app-series-format>
<a class="dark-exempt btn-icon ms-1" routerLink="/library/{{libraryId}}/series/{{series.id}}">
{{series.name}}
</a>
</span>
@ -71,7 +72,7 @@
@if (actions && actions.length > 0) {
<span class="card-actions float-end">
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
</span>
</span>
}
</div>

View file

@ -39,6 +39,7 @@ import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
function deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') {
@ -67,7 +68,7 @@ function deepClone(obj: any): any {
@Component({
selector: 'app-series-card',
standalone: true,
imports: [CommonModule, CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective],
imports: [CommonModule, CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective, SeriesFormatComponent],
templateUrl: './series-card.component.html',
styleUrls: ['./series-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -284,7 +285,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
}
async refreshMetadata(series: Series, forceUpdate = false) {
await this.actionService.refreshSeriesMetadata(series, undefined, forceUpdate);
await this.actionService.refreshSeriesMetadata(series, undefined, forceUpdate, forceUpdate);
}
async scanLibrary(series: Series) {

View file

@ -1,126 +0,0 @@
<ng-container *transloco="let t; read: 'series-info-cards'">
<div class="row g-0 mt-3">
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('release-date-title')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-year-tooltip')">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterField.AgeRating, seriesMetadata.ageRating)" [title]="t('age-rating-title')">
{{this.seriesMetadata.ageRating | ageRating}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('language-title')" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterField.Languages, seriesMetadata.language)" [title]="t('language-title')">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-container>
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title [label]="t('publication-status-title')" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === t('ongoing') ? 'empty' : 'end'}}"
(click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
{{pubStatus}}
</app-icon-and-title>
</ng-container>
</div>
<div class="vr m-2 d-none d-lg-block"></div>
</ng-container>
<ng-container *ngIf="accountService.hasValidLicense$ | async">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('scrobbling-title')" [clickable]="libraryAllowsScrobbling"
fontClasses="fa-solid fa-tower-{{(isScrobbling && libraryAllowsScrobbling) ? 'broadcast' : 'observation'}}"
(click)="toggleScrobbling($event)"
[ngbTooltip]="t('scrobbling-tooltip')">
<ng-container *ngIf="libraryAllowsScrobbling; else noScrobble">
{{ isScrobbling ? t('on') : t('off') }}
</ng-container>
<ng-template #noScrobble>
{{t('disabled')}}
</ng-template>
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series">
<ng-container>
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="series.format | mangaFormatIcon"
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
{{series.format | mangaFormat}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [title]="t('last-read-title')">
{{series.latestReadDate | timeAgo}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: series.wordCount | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: series.pages | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('read-time-title')" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} {{readingTime.minHours > 1 ? t('hours') : t('hour')}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Time Left" [clickable]="false" fontClasses="fa-solid fa-clock">
{{readingTimeLeft | readTimeLeft}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
</ng-container>

View file

@ -1,144 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import {debounceTime, filter, map} from 'rxjs';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {UserProgressUpdateEvent} from 'src/app/_models/events/user-progress-update-event';
import {HourEstimateRange} from 'src/app/_models/series-detail/hour-estimate-range';
import {MangaFormat} from 'src/app/_models/manga-format';
import {Series} from 'src/app/_models/series';
import {SeriesMetadata} from 'src/app/_models/metadata/series-metadata';
import {AccountService} from 'src/app/_services/account.service';
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {ReaderService} from 'src/app/_services/reader.service';
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobblingService} from "../../_services/scrobbling.service";
import {CommonModule} from "@angular/common";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
import {PublicationStatusPipe} from "../../_pipes/publication-status.pipe";
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {ReadTimeLeftPipe} from "../../_pipes/read-time-left.pipe";
@Component({
selector: 'app-series-info-cards',
standalone: true,
imports: [CommonModule, IconAndTitleComponent, AgeRatingPipe, DefaultValuePipe, LanguageNamePipe, PublicationStatusPipe, MangaFormatPipe, TimeAgoPipe, CompactNumberPipe, MangaFormatIconPipe, NgbTooltip, TranslocoDirective, ReadTimeLeftPipe],
templateUrl: './series-info-cards.component.html',
styleUrls: ['./series-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SeriesInfoCardsComponent implements OnInit, OnChanges {
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
private readonly readerService = inject(ReaderService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly messageHub = inject(MessageHubService);
public readonly accountService = inject(AccountService);
private readonly scrobbleService = inject(ScrobblingService);
@Input({required: true}) series!: Series;
@Input({required: true}) seriesMetadata!: SeriesMetadata;
@Input() hasReadingProgress: boolean = false;
@Input() readingTimeLeft: HourEstimateRange | undefined;
/**
* If this should make an API call to request readingTimeLeft
*/
@Input() showReadingTimeLeft: boolean = true;
@Output() goTo: EventEmitter<{queryParamName: FilterField, filter: any}> = new EventEmitter();
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
isScrobbling: boolean = true;
libraryAllowsScrobbling: boolean = true;
protected readonly MangaFormat = MangaFormat;
protected readonly FilterField = FilterField;
constructor() {
// Listen for progress events and re-calculate getTimeLeft
this.messageHub.messages$.pipe(filter(event => event.event === EVENTS.UserProgressUpdate),
map(evt => evt.payload as UserProgressUpdateEvent),
debounceTime(500),
takeUntilDestroyed(this.destroyRef))
.subscribe(updateEvent => {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user === undefined || user.username !== updateEvent.username) return;
if (updateEvent.seriesId !== this.series.id) return;
this.getReadingTimeLeft();
});
});
}
ngOnInit(): void {
if (this.series !== null) {
this.getReadingTimeLeft();
this.readingTime.minHours = this.series.minHoursToRead;
this.readingTime.maxHours = this.series.maxHoursToRead;
this.readingTime.avgHours = this.series.avgHoursToRead;
this.scrobbleService.hasHold(this.series.id).subscribe(res => {
this.isScrobbling = !res;
this.cdRef.markForCheck();
});
this.scrobbleService.libraryAllowsScrobbling(this.series.id).subscribe(res => {
this.libraryAllowsScrobbling = res;
this.cdRef.markForCheck();
});
this.cdRef.markForCheck();
}
}
ngOnChanges() {
this.cdRef.markForCheck();
}
handleGoTo(queryParamName: FilterField, filter: any) {
// Ignore the default case added as this query combo would never be valid
if (filter + '' === '' && queryParamName === FilterField.SeriesName) return;
this.goTo.emit({queryParamName, filter});
}
private getReadingTimeLeft() {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft;
this.cdRef.markForCheck();
});
}
toggleScrobbling(evt: any) {
evt.stopPropagation();
if (this.isScrobbling) {
this.scrobbleService.addHold(this.series.id).subscribe(() => {
this.isScrobbling = !this.isScrobbling;
this.cdRef.markForCheck();
});
} else {
this.scrobbleService.removeHold(this.series.id).subscribe(() => {
this.isScrobbling = !this.isScrobbling;
this.cdRef.markForCheck();
});
}
}
}