Infinite Scroll + List View + Cover Upload Redesign (#1319)

* Started with the redesign of the cover image chooser redesign to be less click intensive for volume/chapter images.

Made some headings bold in card detail drawer.

* Tweaked the styles

* Moved where the info cards show

* Added an ability to open a page settings drawer

* Cleaned up some old code that isn't needed anymore.

* Started implementing a list view. Refactored some title code to a dedicated component

* List view implemented but way too many API calls. Either need caching or adjusting the SeriesDetail api.

* Fixed a bug where if the progress bar didn't render on a card item while a download was in progress, the download indicator would be removed.

* Large refactor to move a lot of the needed fields to the chapter and volume dtos for series detail. All fields are noted when only used in series detail.

* Implemented cards for other tabs (except related)

* Fixed the unit test which needed a mocked reader service call.

* More cleanup around age rating and removing old code from the refactor. Commented out sorting till i feel motivated to work on that.

* Some cleanup and restored cards as initial layout. Time to test this out and see if there is value add.

* Added ability for Chapters tab to show the volume chapters belong to (if applicable)

* Adding style fixes

* Cover image updates, don't allow the first image (which is what is currently set) to respond to cover changes.

Hide the ID field on list item for series detail.

* Refactored the title for list item to be injectable

* Cleaned up the selection code to make it less finicky on mobile when tap scrolling.

* Refactored chapter tab to show volume as well on list view.

* Ensure word count shows for Volumes

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Fixed a bug where all chapters would come under specials

* Show title data as accent if set.

* Style fixes for virtual scroller

* Restyling scroll

* Implemented a way to show storyline with virtual scrolling

* Show Word Count for chapters and cleaned up some logics.

* I might have card layout working with virtual scroll code.

* Some cleanup to hide more system like properties from info bar on series detail page. Fixed some missing time estimate info on storyline chapters.

* Fixed a regression on series service when I integrated VolumeTitle.

* Refactored read time to the backend. Added WordCount to the volume itself so we don't need to calculate on frontend. When asking to analyze files from a series, force the calculation.

* Fixed SeriesDetail api code

* Fixed up the code in the drawer to better update list/card mode

* Basic infinite scroll implemented, however due to how we are updating the list to render, we are re-rending cards that haven't been touched.

* Updated how we render and layout data for infinite scroll on library detail. It's almost there.

* Started laying foundation for loading pages backwards.

Removed lazy loading of images since we are now using virtual paging.

* Hooked in some basic code to allow user to load a prev page with infinite scroll.

* Fixed up series detail api and undid the non-lazy loaded images.

Changed the router to help with this infinite loading on Firefox issue.

* Fixed up some naming issues with Series Detail and added a new test.

* This is an infinite scroll without pagination implementation. It is not fully done, but off to a good start. Virtual scroller with jump bar is working pretty well, def needs more polishing and tweaking. There are hacks in this implementation that need to be revisited.

* Refactored code so that we don't use any pagination and load all results by default.

* Misc code cleanup from build warnings.

* Cleaned up some logic for how to display titles in list view.

* More title cleanup for specials

* Hooked up page layout to user preferences and renamed an existing user pref name to match the dto.

* Swapped out everything but storyline with virtual-scroller over CDK

* Removed CDK from series detail.

* Default value for migration on page layout

* Updating card layout for library detail page

* fixing height for mobile

* Moved scrollbar

* Tweaked some styling for layouts when there is no data

* Refactored the series cards into their own component to make it re-usable.

* More tweaks on series info cards layout and enhanced a few pages with trackby functions.

* Removed some dead code

* Added download on series detail to actionables to fit in with new scroll strategy.

* Fixed language not being updated and sent to the backend for series update.

* Fixed a bad migration (if you ran any prior migration in this branch, you need to undo before you use this commit)

* Adding sticky tabs

* fixed mobile gap on sticky tab

* Enhanced the card title for books to show number up front.

* Adjusted the gutters on admin dashboard

* Removed debug code

* Removing duplicate book title

* Cleaned up old references to cdk scroller

* Implemented a basic jump bar scaling algorithm. Not perfect, but works pretty well.

* Code smells

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-06-13 16:37:49 -05:00 committed by GitHub
parent f0f0e23e88
commit bbc48a5f5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 7863 additions and 2097 deletions

View file

@ -338,11 +338,13 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
return a.isoCode == b.isoCode;
}
if (this.metadata.language) {
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
if (this.metadata.language === undefined || this.metadata.language === null || this.metadata.language === '') {
this.metadata.language = 'en';
}
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
return of(true);
}
@ -428,6 +430,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
model.nameLocked = this.series.nameLocked;
model.sortNameLocked = this.series.sortNameLocked;
model.localizedNameLocked = this.series.localizedNameLocked;
model.language = this.metadata.language;
apis.push(this.seriesService.updateSeries(model));
}
@ -459,8 +462,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.metadata.genres = genres;
}
updateLanguage(language: Language) {
this.metadata.language = language.isoCode;
updateLanguage(language: Array<Language>) {
if (language.length === 0) {
this.metadata.language = '';
return;
}
this.metadata.language = language[0].isoCode;
}
updatePerson(persons: Person[], role: PersonRole) {

View file

@ -1,27 +0,0 @@
<div class="card" *ngIf="bookmark != undefined">
<app-image height="230px" width="170px" [imageUrl]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"></app-image>
<div class="card-body" *ngIf="bookmark.page >= 0">
<div class="header-row">
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-end" *ngIf="series != undefined">
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
<ng-container *ngIf="isClearing; else notClearing">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</ng-template>
</button>
</span>
</div>
<div>
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
</div>
</div>
</div>

View file

@ -1,25 +0,0 @@
.card-body {
padding: 5px;
}
.card {
margin-left: 5px;
margin-right: 5px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-overflow {
font-size: 13px;
width: 130px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
margin-top: 2px;
margin-bottom: 0px;
}

View file

@ -1,43 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { PageBookmark } from '../../_models/page-bookmark';
@Component({
selector: 'app-bookmark',
templateUrl: './bookmark.component.html',
styleUrls: ['./bookmark.component.scss']
})
export class BookmarkComponent implements OnInit {
@Input() bookmark: PageBookmark | undefined;
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
series: Series | undefined;
isClearing: boolean = false;
isDownloading: boolean = false;
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.bookmark) {
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
this.series = series;
});
}
}
handleClick(event: any) {
}
removeBookmark() {
if (this.bookmark === undefined) return;
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
this.bookmarkRemoved.emit(this.bookmark);
this.bookmark = undefined;
});
}
}

View file

@ -21,7 +21,7 @@ export class BulkOperationsComponent implements OnInit {
ngOnInit(): void {
const navBar = document.querySelector('.navbar');
if (navBar) {
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height);
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height); // TODO: We can make this fixed 63px
}
}

View file

@ -1,32 +1,8 @@
<div class="offcanvas-header">
<h5 class="offcanvas-title">
<ng-container [ngSwitch]="libraryType">
<ng-container *ngSwitchCase="LibraryType.Comic">
<span class="modal-title" id="modal-basic-title">
<ng-container *ngIf="chapter.titleName != ''; else fullComicTitle">
{{chapter.titleName}}
</ng-container>
<ng-template #fullComicTitle>
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}}
</ng-template>
</span>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Manga">
<span class="modal-title" id="modal-basic-title">
<ng-container *ngIf="chapter.titleName != ''; else fullMangaTitle">
{{chapter.titleName}}
</ng-container>
<ng-template #fullMangaTitle>
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}}
</ng-template>
</span>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Book">
<span class="modal-title" id="modal-basic-title">
{{chapter.titleName}}
</span>
</ng-container>
</ng-container>
<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>
@ -44,7 +20,7 @@
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</div>
<div class="col-md-10 col-lg-11">
<ng-container *ngIf="summary$ | async as summary; else noSummary">
<ng-container *ngIf="summary.length > 0; else noSummary">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</ng-container>
<ng-template #noSummary>
@ -53,73 +29,8 @@
</div>
</div>
<div class="row g-0 mt-4 mb-3">
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
{{totalPages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<app-entity-info-cards [entity]="data"></app-entity-info-cards>
<ng-container *ngIf="chapterMetadata !== undefined && chapterMetadata.releaseDate && (chapterMetadata.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
{{chapterMetadata.releaseDate | date:'shortDate'}}
</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 && chapterMetadata !== undefined && chapterMetadata.wordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && chapterMetadata !== undefined && chapterMetadata.wordCount > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{chapterMetadata.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapterMetadata !== undefined">
<ng-container *ngIf="ageRating !== '' && ageRating !== 'Unknown'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
{{ageRating}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="chapter.created && chapter.created !== '' && (chapter.created | 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="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
{{chapter.created | date:'short' || '-'}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
{{data.id}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<!-- 2 rows to show some tags-->
<ng-container *ngIf="chapterMetadata !== undefined">
@ -187,18 +98,13 @@
<li [ngbNavItem]="tabs[TabID.Cover]">
<a ngbNavLink>{{tabs[TabID.Cover].title}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
<div class="row g-0">
<button class="btn btn-primary flex-end mb-2" [disabled]="coverImageSaveLoading" (click)="saveCoverImage()">
<ng-container *ngIf="coverImageSaveLoading; else notSaving">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notSaving>
Save
</ng-template>
</button>
</div>
<app-cover-image-chooser [(imageUrls)]="imageUrls"
[showReset]="chapter.coverImageLocked"
[showApplyButton]="true"
(applyCover)="applyCoverImage($event)"
(resetCover)="resetCoverImage()"
>
</app-cover-image-chooser>
</ng-template>
</li>

View file

@ -14,3 +14,7 @@
overflow: auto;
height: calc(40vh - 63px); // drawer height - offcanvas heading height
}
.h6 {
font-weight: 600;
}

View file

@ -2,7 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { NgbActiveOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, of, take, takeWhile, tap } from 'rxjs';
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
@ -50,21 +50,6 @@ export class CardDetailDrawerComponent implements OnInit {
isChapter = false;
chapters: Chapter[] = [];
/**
* If a cover image update occured.
*/
coverImageUpdate: boolean = false;
coverImageIndex: number = 0;
/**
* Url of the selected cover
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
/**
* When the API is doing work
*/
coverImageSaveLoading: boolean = false;
imageUrls: Array<string> = [];
@ -77,16 +62,7 @@ export class CardDetailDrawerComponent implements OnInit {
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
ageRating!: string;
summary$: Observable<string> = of('');
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
/**
* We use a separate variable because if this is a volume, we need a sum of all chapters
*/
totalPages: number = 0;
summary: string = '';
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
@ -129,25 +105,14 @@ export class CardDetailDrawerComponent implements OnInit {
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
this.totalPages = this.chapter.pages;
if (!this.isChapter) {
// Need to account for multiple chapters if this is a volume
this.totalPages = this.utilityService.asVolume(this.data).chapters.map(c => c.pages).reduce((sum, d) => sum + d);
}
this.readerService.getManualTimeToRead(this.chapterMetadata.wordCount, this.totalPages, this.chapter.files[0].format === MangaFormat.EPUB).subscribe((time) => this.readingTime = time);
});
if (this.isChapter) {
this.summary$ = this.metadataService.getChapterSummary(this.data.id);
this.summary = this.utilityService.asChapter(this.data).summary || '';
} else {
this.summary$ = this.metadataService.getChapterSummary(this.utilityService.asVolume(this.data).chapters[0].id);
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
}
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
@ -180,7 +145,7 @@ export class CardDetailDrawerComponent implements OnInit {
}
close() {
this.activeOffcanvas.close({coverImageUpdate: this.coverImageUpdate});
this.activeOffcanvas.close();
}
formatChapterNumber(chapter: Chapter) {
@ -196,36 +161,14 @@ export class CardDetailDrawerComponent implements OnInit {
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
applyCoverImage(coverUrl: string) {
this.uploadService.updateChapterCoverImage(this.chapter.id, coverUrl).subscribe(() => {});
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
}
saveCoverImage() {
this.coverImageSaveLoading = true;
const selectedIndex = this.coverImageIndex || 0;
if (selectedIndex > 0) {
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.coverImageUpdate = true;
}
this.coverImageSaveLoading = false;
}, err => this.coverImageSaveLoading = false);
} else if (this.coverImageLocked === false) {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('Cover image reset');
this.coverImageSaveLoading = false;
this.coverImageUpdate = true;
});
}
resetCoverImage() {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('A job has been enqueued to regenerate the cover image');
});
}
markChapterAsRead(chapter: Chapter) {
@ -292,13 +235,10 @@ export class CardDetailDrawerComponent implements OnInit {
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
console.log('want to download: ', wantToDownload);
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
tap(val => {
console.log(val);
}),
takeWhile(val => {
return val.state != 'DONE';
}),

View file

@ -13,29 +13,39 @@
</div>
</div>
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<div class="viewport-container">
<div class="viewport-container" #scrollingBlock>
<div class="content-container">
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="card-container mt-2 mb-2">
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
<virtual-scroller #scroll [items]="items" (vsEnd)="fetchMore($event)" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</virtual-scroller>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
</div>
<ng-container *ngIf="pagination && items.length > 0 && pagination.totalPages > 1" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
<ng-container [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
</div>
<ng-template #cardTemplate>
<div class="grid row g-0" >
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</virtual-scroller>
<div class="mx-auto" *ngIf="items.length === 0 && !isLoading" style="width: 200px;">
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
</div>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</ng-template>
<ng-template #paginationTemplate let-id="id">
@ -93,8 +103,8 @@
<ng-template #jumpBar>
<div class="jump-bar">
<ng-container *ngFor="let jumpKey of jumpBarKeys; let i = index;">
<button class="btn btn-link {{i % 2 !== 0 ? 'd-lg-flex' : 'd-md-flex'}}" (click)="scrollTo(jumpKey)">
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
{{jumpKey.title}}
</button>
</ng-container>

View file

@ -17,7 +17,7 @@
.card-container {
display: inline-block;
width: 100%;
overflow-y: auto;
//overflow-y: auto;
}
.grid {
@ -73,3 +73,12 @@
}
}
}
.virtual-scroller, virtual-scroller {
width: 100%;
//height: calc(100vh - 160px); // 64 is a random number, 523 for me.
height: calc(var(--vh) * 100 - 160px);
//height: calc(100vh - 160px);
//background-color: red;
//max-height: calc(var(--vh)*100 - 170px);
}

View file

@ -1,28 +1,33 @@
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DOCUMENT } from '@angular/common';
import { AfterViewInit, Component, ContentChild, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { from, Subject } from 'rxjs';
import { AfterViewInit, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
import { IPageInfo, VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
import { filter, from, map, pairwise, Subject, tap, throttleTime } from 'rxjs';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Library } from 'src/app/_models/library';
import { Pagination } from 'src/app/_models/pagination';
import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
const FILTER_PAG_REGEX = /[^0-9]/g;
const SCROLL_BREAKPOINT = 300;
const keySize = 24;
@Component({
selector: 'app-card-detail-layout',
templateUrl: './card-detail-layout.component.html',
styleUrls: ['./card-detail-layout.component.scss']
})
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
@Input() header: string = '';
@Input() isLoading: boolean = false;
@Input() items: any[] = [];
// ?! we need to have chunks to render in, because if we scroll down, then up, then down, we don't want to trigger a duplicate call
@Input() paginatedItems: PaginatedResult<any> | undefined;
@Input() pagination!: Pagination;
// Filter Code
@ -35,56 +40,119 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = [];
@Input() trackByIdentity!: (index: number, item: any) => string;
@Input() trackByIdentity!: TrackByFunction<any>; //(index: number, item: any) => string
@Input() filterSettings!: FilterSettings;
@Input() jumpBarKeys: Array<JumpKey> = []; // This is aprox 784 pixels wide
jumpBarKeysToRender: Array<JumpKey> = []; // Original
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() pageChangeWithDirection: EventEmitter<0 | 1> = new EventEmitter();
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
@ViewChild('.jump-bar') jumpBar!: ElementRef<HTMLDivElement>;
@ViewChild('scroller') scroller!: CdkVirtualScrollViewport;
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
itemSize: number = 100; // Idk what this actually does. Less results in more items rendering, 5 works well with pagination. 230 is technically what a card is height wise
filter!: SeriesFilter;
libraries: Array<FilterItem<Library>> = [];
updateApplied: number = 0;
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
private onDestory: Subject<void> = new Subject();
get Breakpoint() {
return Breakpoint;
}
constructor(private seriesService: SeriesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) {
constructor(private seriesService: SeriesService, public utilityService: UtilityService,
@Inject(DOCUMENT) private document: Document, private ngZone: NgZone) {
this.filter = this.seriesService.createSeriesFilter();
}
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
resizeJumpBar() {
console.log('resizing jump bar');
//const breakpoint = this.utilityService.getActiveBreakpoint();
// if (window.innerWidth < 784) {
// // We need to remove a few sections of keys
// const len = this.jumpBarKeys.length;
// if (this.jumpBarKeys.length <= 8) return;
// this.jumpBarKeys = this.jumpBarKeys.filter((item, index) => {
// return index % 2 === 0;
// });
// }
// TODO: Debounce this
const fullSize = (this.jumpBarKeys.length * keySize) - 20;
const currentSize = (this.document.querySelector('.jump-bar')?.getBoundingClientRect().height || fullSize + 20) - 20;
if (currentSize >= fullSize) {
return;
}
const targetNumberOfKeys = parseInt(Math.round(currentSize / keySize) + '', 10);
const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3;
if (removeCount <= 0) return;
this.jumpBarKeysToRender = [];
const midPoint = this.jumpBarKeys.length / 2;
this.jumpBarKeysToRender.push(this.jumpBarKeys[0]);
this.removeFirstPartOfJumpBar(midPoint, removeCount / 2);
this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]);
this.removeSecondPartOfJumpBar(midPoint, removeCount / 2);
this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]);
//console.log('End product: ', this.jumpBarKeysToRender);
// console.log('End key size: ', this.jumpBarKeysToRender.length);
}
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
let minIndex = -1;
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
min = this.jumpBarKeys[i].size;
minIndex = i;
}
}
removedIndexes.push(minIndex);
}
// console.log('second: removing ', removedIndexes);
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
}
}
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
let minIndex = -1;
for(let i = 1; i < midPoint; i++) {
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
min = this.jumpBarKeys[i].size;
minIndex = i;
}
}
removedIndexes.push(minIndex);
}
// console.log('first: removing ', removedIndexes);
for(let i = 1; i < midPoint; i++) {
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
}
}
ngOnInit(): void {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}_${item?.libraryId}`;
if (this.trackByIdentity === undefined) {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; // ${this.pagination?.currentPage}_
}
if (this.filterSettings === undefined) {
@ -96,27 +164,49 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
}
}
ngOnChanges(changes: SimpleChanges): void {
this.jumpBarKeysToRender = [...this.jumpBarKeys];
}
ngAfterViewInit() {
this.resizeJumpBar();
// this.scroller.elementScrolled().pipe(
// map(() => this.scroller.measureScrollOffset('bottom')),
// pairwise(),
// filter(([y1, y2]) => ((y2 < y1 && y2 < SCROLL_BREAKPOINT))), // 140
// throttleTime(200)
// ).subscribe(([y1, y2]) => {
// const movingForward = y2 < y1;
// if (this.pagination.currentPage === this.pagination.totalPages || this.pagination.currentPage === 1 && !movingForward) return;
// this.ngZone.run(() => {
// console.log('Load next pages');
const parent = this.document.querySelector('.card-container');
if (parent == null) return;
console.log('card divs', this.document.querySelectorAll('div[id^="jumpbar-index--"]'));
console.log('cards: ', this.document.querySelectorAll('.card'));
// this.pagination.currentPage = this.pagination.currentPage + 1;
// this.pageChangeWithDirection.emit(1);
// });
// });
Array.from(this.document.querySelectorAll('div')).forEach(elem => this.intersectionObserver.observe(elem));
// this.scroller.elementScrolled().pipe(
// map(() => this.scroller.measureScrollOffset('top')),
// pairwise(),
// filter(([y1, y2]) => y2 >= y1 && y2 < SCROLL_BREAKPOINT),
// throttleTime(200)
// ).subscribe(([y1, y2]) => {
// if (this.pagination.currentPage === 1) return;
// this.ngZone.run(() => {
// console.log('Load prev pages');
// this.pagination.currentPage = this.pagination.currentPage - 1;
// this.pageChangeWithDirection.emit(0);
// });
// });
}
ngOnDestroy() {
this.intersectionObserver.disconnect();
this.onDestory.next();
this.onDestory.complete();
}
handleIntersection(entries: IntersectionObserverEntry[]) {
console.log('interception: ', entries.filter(e => e.target.hasAttribute('no-observe')));
}
onPageChange(page: number) {
this.pageChange.emit(this.pagination);
@ -142,18 +232,21 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
this.updateApplied++;
}
// onScroll() {
loading: boolean = false;
fetchMore(event: IPageInfo) {
if (event.endIndex !== this.items.length - 1) return;
if (event.startIndex < 0) return;
console.log('Requesting next page ', (this.pagination.currentPage + 1), 'of data', event);
this.loading = true;
// }
// this.pagination.currentPage = this.pagination.currentPage + 1;
// this.pageChangeWithDirection.emit(1);
// onScrollDown() {
// console.log('scrolled down');
// }
// onScrollUp() {
// console.log('scrolled up');
// }
// this.fetchNextChunk(this.items.length, 10).then(chunk => {
// this.items = this.items.concat(chunk);
// this.loading = false;
// }, () => this.loading = false);
}
scrollTo(jumpKey: JumpKey) {
// TODO: Figure out how to do this
@ -165,6 +258,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
}
//console.log('scrolling to card that starts with ', jumpKey.key, + ' with index of ', targetIndex);
// Infinite scroll
this.virtualScroller.scrollToIndex(targetIndex, true, undefined, 1000);
return;
// Basic implementation based on itemsPerPage being the same.
//var minIndex = this.pagination.currentPage * this.pagination.itemsPerPage;
var targetPage = Math.max(Math.ceil(targetIndex / this.pagination.itemsPerPage), 1);
@ -173,14 +270,18 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
// Scroll to the element
const elem = this.document.querySelector(`div[id="jumpbar-index--${targetIndex}"`);
if (elem !== null) {
elem.scrollIntoView({
behavior: 'smooth'
});
this.virtualScroller.scrollToIndex(targetIndex);
// elem.scrollIntoView({
// behavior: 'smooth'
// });
}
return;
}
// With infinite scroll, we can't just jump to a random place, because then our list of items would be out of sync.
this.selectPageStr(targetPage + '');
//this.pageChangeWithDirection.emit(1);
// if (minIndex > targetIndex) {
// // We need to scroll forward (potentially to another page)

View file

@ -7,8 +7,8 @@
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
</ng-container>
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== total">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
<div class="progress-banner">
<p *ngIf="read < total && total > 0 && read !== total"><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>

View file

@ -19,6 +19,7 @@ import { Action, 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';
@Component({
@ -129,7 +130,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
constructor(public imageService: ImageService, private libraryService: LibraryService,
public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService,
private messageHub: MessageHubService, private accountService: AccountService) {}
private messageHub: MessageHubService, private accountService: AccountService, private scrollService: ScrollService) {}
ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
@ -182,28 +183,18 @@ export class CardItemComponent implements OnInit, OnDestroy {
@HostListener('touchstart', ['$event'])
onTouchStart(event: TouchEvent) {
if (!this.allowSelection) return;
const verticalOffset = (window.pageYOffset
|| document.documentElement.scrollTop
|| document.body.scrollTop || 0);
this.prevTouchTime = event.timeStamp;
this.prevOffset = verticalOffset;
this.prevOffset = this.scrollService.scrollPosition;
}
@HostListener('touchend', ['$event'])
onTouchEnd(event: TouchEvent) {
if (!this.allowSelection) return;
const delta = event.timeStamp - this.prevTouchTime;
const verticalOffset = (window.pageYOffset
|| document.documentElement.scrollTop
|| document.body.scrollTop || 0);
const verticalOffset = this.scrollService.scrollPosition;
if (verticalOffset != this.prevOffset) {
this.prevTouchTime = 0;
return;
}
if (delta >= 300 && delta <= 1000) {
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset)) {
this.handleSelection();
event.stopPropagation();
event.preventDefault();

View file

@ -23,6 +23,12 @@ import { FileInfoComponent } from './file-info/file-info.component';
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component';
import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-drawer.component';
import { EntityTitleComponent } from './entity-title/entity-title.component';
import { EntityInfoCardsComponent } from './entity-info-cards/entity-info-cards.component';
import { ListItemComponent } from './list-item/list-item.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component';
@ -43,6 +49,10 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
FileInfoComponent,
EditSeriesRelationComponent,
CardDetailDrawerComponent,
EntityTitleComponent,
EntityInfoCardsComponent,
ListItemComponent,
SeriesInfoCardsComponent,
],
imports: [
CommonModule,
@ -60,8 +70,7 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
NgbCollapseModule,
NgbRatingModule,
//ScrollingModule,
//InfiniteScrollModule,
VirtualScrollerModule,
NgbOffcanvasModule, // Series Detail, action of cards
@ -93,7 +102,16 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
ChapterMetadataDetailComponent,
EditSeriesRelationComponent,
NgbOffcanvasModule
EntityTitleComponent,
EntityInfoCardsComponent,
ListItemComponent,
NgbOffcanvasModule,
VirtualScrollerModule,
SeriesInfoCardsComponent
]
})
export class CardsModule { }

View file

@ -48,11 +48,27 @@
</form>
<div class="row g-0 chooser" style="padding-top: 10px">
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
<div class="image-card col-auto"
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)"
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url" [processEvents]="idx > 0"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button class="btn btn-primary" style="width: 100%;" aria-label="Apply for uploaded image"
(click)="applyImage(idx)">
{{appliedIndex === idx ? 'Applied' : 'Apply'}}
</button>
</ng-container>
</div>
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
<div class="image-card col-auto"
*ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()"
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
</ng-container>
</div>
</div>

View file

@ -9,6 +9,8 @@ import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { UploadService } from 'src/app/_services/upload.service';
import { DOCUMENT } from '@angular/common';
export type SelectCoverFunction = (selectedCover: string) => void;
@Component({
selector: 'app-cover-image-chooser',
templateUrl: './cover-image-chooser.component.html',
@ -16,6 +18,19 @@ import { DOCUMENT } from '@angular/common';
})
export class CoverImageChooserComponent implements OnInit, OnDestroy {
/**
* If buttons show under images to allow immediate selection of cover images.
*/
@Input() showApplyButton: boolean = false;
/**
* When a cover image is selected, this will be called with a base url representation of the file.
*/
@Output() applyCover: EventEmitter<string> = new EventEmitter<string>();
/**
* When a cover image is reset, this will be called.
*/
@Output() resetCover: EventEmitter<void> = new EventEmitter<void>();
@Input() imageUrls: Array<string> = [];
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
@ -37,6 +52,10 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
selectedIndex: number = 0;
/**
* Only applies for showApplyButton. Used to track which image is applied.
*/
appliedIndex: number = 0;
form!: FormGroup;
files: NgxFileDropEntry[] = [];
@ -78,6 +97,19 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
}
applyImage(index: number) {
if (this.showApplyButton) {
this.applyCover.emit(this.imageUrls[index]);
this.appliedIndex = index;
}
}
resetImage() {
if (this.showApplyButton) {
this.resetCover.emit();
}
}
loadImage() {
const url = this.form.get('coverImageUrl')?.value.trim();
if (url && url != '') {

View file

@ -0,0 +1,66 @@
<div class="row g-0 mt-4 mb-3">
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
{{totalPages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<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="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
{{chapter.releaseDate | date:'shortDate'}}
</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="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{totalWordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.ageRating !== AgeRating.Unknown">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
{{chapter.ageRating | ageRating | async}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && chapter.created && chapter.created !== '' && (chapter.created | 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="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
{{chapter.created | date:'short' || '-'}}
</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="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
{{entity.id}}
</app-icon-and-title>
</div>
</ng-container>
</div>

View file

@ -0,0 +1,96 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { LibraryType } from 'src/app/_models/library';
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';
@Component({
selector: 'app-entity-info-cards',
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss']
})
export class EntityInfoCardsComponent implements OnInit, OnDestroy {
@Input() entity!: Volume | Chapter;
/**
* This will pull extra information
*/
@Input() includeMetadata: boolean = false;
/**
* Hide more system based fields, like Id or Date Added
*/
@Input() showExtendedProperties: boolean = true;
isChapter = false;
chapter!: Chapter;
chapterMetadata!: ChapterMetadata;
ageRating!: string;
totalPages: number = 0;
totalWordCount: number = 0;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
private readonly onDestroy: Subject<void> = new Subject();
get LibraryType() {
return LibraryType;
}
get MangaFormat() {
return MangaFormat;
}
get AgeRating() {
return AgeRating;
}
constructor(private utilityService: UtilityService, private seriesService: SeriesService) {}
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.includeMetadata) {
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
});
}
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;
}
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -0,0 +1,29 @@
<ng-container [ngSwitch]="libraryType">
<ng-container *ngSwitchCase="LibraryType.Comic">
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullComicTitle">
{{titleName}}
</ng-container>
<ng-template #fullComicTitle>
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
<ng-container *ngIf="includeVolume && volumeTitle != ''">
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
</ng-container>
{{entity.number != 0 ? (isChapter ? 'Issue #' + entity.number : volumeTitle) : 'Special'}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Manga">
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullMangaTitle">
{{titleName}}
</ng-container>
<ng-template #fullMangaTitle>
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
<ng-container *ngIf="includeVolume && volumeTitle != ''">
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
</ng-container>
{{entity.number != 0 ? (isChapter ? 'Chapter ' + entity.number : volumeTitle) : 'Special'}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Book">
{{volumeTitle}}
</ng-container>
</ng-container>

View file

@ -0,0 +1,57 @@
import { Component, Input, OnInit } from '@angular/core';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
import { Volume } from 'src/app/_models/volume';
@Component({
selector: 'app-entity-title',
templateUrl: './entity-title.component.html',
styleUrls: ['./entity-title.component.scss']
})
export class EntityTitleComponent implements OnInit {
/**
* Library type for which the entity belongs
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
@Input() seriesName: string = '';
@Input() entity!: Volume | Chapter;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* When a titleName (aka a title) is avaliable on the entity, show it over Volume X Chapter Y
*/
@Input() prioritizeTitleName: boolean = true;
isChapter = false;
chapter!: Chapter;
titleName: string = '';
volumeTitle: string = '';
get LibraryType() {
return LibraryType;
}
constructor(private utilityService: UtilityService) {
}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
const c = (this.entity as Chapter);
this.volumeTitle = c.volumeTitle || '';
this.titleName = c.titleName || '';
} else {
const v = this.utilityService.asVolume(this.entity);
this.volumeTitle = v.name || '';
this.titleName = v.chapters[0].titleName || '';
}
}
}

View file

@ -0,0 +1,40 @@
<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" [width]="imageWidth"></app-image>
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="visually-hidden" role="status">
{{download.progress}}% downloaded
</span>
</span>
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
<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 style="margin-bottom: 0px">
<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">Read</span>
</button>
</h5>
<!-- This isn't perfect, but it might work. TODO: Polish this-->
<h6 class="text-muted" [ngClass]="{'subtitle-with-actionables' : actions.length > 0}" style="font-size: 0.75rem" *ngIf="Title != '' && showTitle">{{Title}}</h6>
<ng-container *ngIf="summary.length > 0">
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
</ng-container>
<div class="ps-2 d-none d-md-inline-block">
<app-entity-info-cards [entity]="entity" [showExtendedProperties]="false"></app-entity-info-cards>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
$image-height: 230px;
$image-width: 160px;
.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: rgb(0,0,0);
background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);
border-radius: 5px;
position: relative;
}

View file

@ -0,0 +1,140 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { 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 { LibraryType } from 'src/app/_models/library';
import { Series } from 'src/app/_models/series';
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';
@Component({
selector: 'app-list-item',
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.scss']
})
export class ListItemComponent implements OnInit {
/**
* Volume or Chapter to render
*/
@Input() entity!: Volume | Chapter;
/**
* 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 230px.
*/
@Input() imageHeight: string = '230px';
/**
* Size of the Image Width Defaults to 158px.
*/
@Input() imageWidth: string = '158px';
@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 avaible on entity
*/
@Input() showTitle: boolean = true;
@Output() read: EventEmitter<void> = new EventEmitter<void>();
actionInProgress: boolean = false;
summary$: Observable<string> = of('');
summary: string = '';
isChapter: boolean = false;
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
get Title() {
if (this.isChapter) return (this.entity as Chapter).titleName;
return '';
}
constructor(private utilityService: UtilityService, private downloadService: DownloadService, private toastr: ToastrService) { }
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 || '';
}
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
if (this.downloadInProgress === true) {
this.toastr.info('Download is already in progress. Please wait.');
return;
}
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadVolume(volume).pipe(
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
}));
});
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
}));
});
}
return; // Don't propagate the download from a card
}
if (typeof action.callback === 'function') {
action.callback(action.action, this.entity);
}
}
}

View file

@ -72,7 +72,8 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
//this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id); // TODO: Do I need to do this since image now handles updates?
}
}

View file

@ -0,0 +1,103 @@
<div class="row g-0 mb-4 mt-3">
<ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Age Rating" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" title="Age Rating">
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Release Year" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Language" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterQueryParam.Languages, seriesMetadata.language)" title="Language">
{{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="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title label="Publication" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === 'Ongoing' ? 'empty' : 'end'}}" (click)="handleGoTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{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="series">
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Format" [clickable]="true" [fontClasses]="'fa ' + utilityService.mangaFormatIcon(series.format)" (click)="handleGoTo(FilterQueryParam.Format, series.format)" title="Format">
{{utilityService.mangaFormat(series.format)}}
</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-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Last Read" [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</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-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</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-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages | number:''}} Pages
</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-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</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-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Left" [clickable]="false" fontClasses="fa-solid fa-clock">
~{{readingTimeLeft.avgHours}} Hour{{readingTimeLeft.avgHours > 1 ? 's' : ''}} Left
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>

View file

@ -0,0 +1,55 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/series-metadata';
import { MetadataService } from 'src/app/_services/metadata.service';
import { ReaderService } from 'src/app/_services/reader.service';
@Component({
selector: 'app-series-info-cards',
templateUrl: './series-info-cards.component.html',
styleUrls: ['./series-info-cards.component.scss']
})
export class SeriesInfoCardsComponent implements OnInit {
@Input() series!: Series;
@Input() 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: FilterQueryParam, filter: any}> = new EventEmitter();
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
get MangaFormat() {
return MangaFormat;
}
get FilterQueryParam() {
return FilterQueryParam;
}
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.series !== null) {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
this.readingTime.minHours = this.series.minHoursToRead;
this.readingTime.maxHours = this.series.maxHoursToRead;
this.readingTime.avgHours = this.series.avgHoursToRead;
}
}
handleGoTo(queryParamName: FilterQueryParam, filter: any) {
this.goTo.emit({queryParamName, filter});
}
}