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

@ -1,4 +1,4 @@
<app-side-nav-companion-bar *ngIf="series !== undefined">
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<h2 style="margin-bottom: 0px">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
@ -8,9 +8,52 @@
<ng-container subtitle *ngIf="series?.localizedName !== series?.name">
<h6 class="subtitle-with-actionables" title="Localized Name">{{series?.localizedName}}</h6>
</ng-container>
<ng-template #extrasDrawer let-offcanvas>
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">Page Settings</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body">
<form [formGroup]="pageExtrasGroup">
<!-- <div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">Sort Order</label>
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
<i class="fa fa-arrow-up" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
<ng-template #descSort>
<i class="fa fa-arrow-down" title="Descending"></i>
</ng-template>
</button>
<select class="form-select" aria-describedby="settings-reading-direction-help" formControlName="sortingOption">
<option *ngFor="let opt of sortingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div> -->
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label id="list-layout-mode-label" class="form-label">Layout Mode</label>
<br/>
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">Card</label>
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">List</label>
</div>
</div>
</div>
</form>
</div>
</ng-template>
</app-side-nav-companion-bar>
<div class="container-fluid pt-2" *ngIf="series !== undefined">
<div class="row mb-3">
<div (scroll)="onScroll()" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
<div class="row mb-3 info-container">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="300px" [imageUrl]="seriesImage"></app-image>
<!-- NOTE: We can put continue point here as Vol X Ch Y or just Ch Y or Book Z ?-->
@ -37,7 +80,7 @@
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
</div>
</div>
<div class="col-auto ms-2" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
<ng-container *ngIf="downloadInProgress; else notDownloading">
@ -59,82 +102,173 @@
<app-read-more class="user-review {{userReview ? 'mt-1' : ''}}" [text]="series?.userReview || ''" [maxLength]="250"></app-read-more>
</div>
<div *ngIf="seriesMetadata" class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"></app-series-metadata-detail>
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series" [hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
</div>
</div>
<ng-container>
<ng-container *ngIf="series">
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<li [ngbNavItem]="TabID.Storyline" *ngIf="libraryType !== LibraryType.Book && (volumes.length > 0 || chapters.length > 0)">
<a ngbNavLink>Storyline</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="volume.number != 0" [entity]="volume" [title]="volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
<ng-container *ngIf="!item.isChapter; else chapterCardItem">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="item.volume.number != 0" [entity]="item.volume" [title]="item.volume.name" (click)="openVolume(item.volume)"
[imageUrl]="imageService.getVolumeCoverImage(item.volume.id)"
[read]="item.volume.pagesRead" [total]="item.volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
</ng-container>
<ng-template #chapterCardItem>
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.chapter.isSpecial" [entity]="item.chapter" [title]="item.chapter.title" (click)="openChapter(item.chapter)"
[imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
[read]="item.chapter.pagesRead" [total]="item.chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, storyChapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</ng-template>
</ng-container>
</div>
</ng-container>
<ng-container *ngFor="let chapter of storyChapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="chapter.title" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, storyChapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</ng-container>
</div>
<ng-template #storylineListLayout>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
<ng-container *ngIf="!item.isChapter; else chapterListItem">
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.volume.id)"
[seriesName]="series.name" [entity]="item.volume" *ngIf="item.volume.number != 0"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.volume.pagesRead" [totalPages]="item.volume.pages" (read)="openVolume(item.volume)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item.volume" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</ng-container>
<ng-template #chapterListItem>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
[seriesName]="series.name" [entity]="item.chapter" *ngIf="!item.chapter.isSpecial"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.chapter.pagesRead" [totalPages]="item.chapter.pages" (read)="openChapter(item.chapter)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item.chapter" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</ng-template>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Volumes" *ngIf="volumes.length > 0">
<a ngbNavLink>{{libraryType === LibraryType.Book ? 'Books': 'Volumes'}}</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true">
</app-card-item>
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true">
</app-card-item>
</ng-container>
</div>
</ng-container>
</div>
<ng-template #volumeListLayout>
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[seriesName]="series.name" [entity]="volume" *ngIf="volume.number != 0"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="volume.pagesRead" [totalPages]="volume.pages" (read)="openVolume(volume)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="volume" [seriesName]="series.name"></app-entity-title>
</ng-container>
</app-list-item>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
<li [ngbNavItem]="TabID.Chapters" *ngIf="chapters.length > 0">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of chapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout">
<div class="card-container row g-0" #container>
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [includeVolume]="true"></app-entity-title>
</ng-container>
</app-card-item>
</div>
</div>
</ng-container>
</div>
<ng-template #chapterListLayout>
<div *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-list-item [imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[seriesName]="series.name" [entity]="chapter" *ngIf="!chapter.isSpecial"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="chapter.pagesRead" [totalPages]="chapter.pages" (read)="openChapter(chapter)"
[includeVolume]="true">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="chapter" [seriesName]="series.name" [includeVolume]="true" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</div>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of specials; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title || item.range" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('special', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true"></app-card-item>
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title || item.range" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('special', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true">
</app-card-item>
</ng-container>
</div>
</ng-container>
</div>
<ng-template #specialListLayout>
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-list-item [imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[seriesName]="series.name" [entity]="chapter"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="chapter.pagesRead" [totalPages]="chapter.pages" (read)="openChapter(chapter)">
<ng-container title>
{{chapter.title || chapter.range}}
</ng-container>
</app-list-item>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
<a ngbNavLink>Related</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of relations; let idx = index; trackBy: trackByRelatedSeriesIdentiy">
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-container>
</div>
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems let idx = index; trackBy: trackByRelatedSeriesIdentiy">
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-container>
</div>
</virtual-scroller>
</ng-template>
</li>
</ul>
@ -147,3 +281,4 @@
</div>
</div>
</div>

View file

@ -13,3 +13,24 @@
grid-gap: 0.5rem;
justify-content: space-around;
}
.virtual-scroller, virtual-scroller {
width: 100%;
//height: 10000px;
height: calc(100vh - 85px);
max-height: calc(var(--vh)*100 - 170px);
}
// This is responsible for ensuring we scroll down and only tabs and companion bar is visible
.main-container {
height: calc(var(--vh)*100 - 117px);
overflow-y: auto;
}
.nav-tabs.fixed {
position: fixed;
top: 114px;
z-index: 100;
background-color: var(--bs-body-bg);
width: 100%;
}

View file

@ -1,12 +1,11 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { finalize, mergeMap, take, takeUntil, takeWhile } from 'rxjs/operators';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service';
@ -36,6 +35,9 @@ import { NavService } from '../_services/nav.service';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { RelationKind } from '../_models/series-detail/relation-kind';
import { CardDetailDrawerComponent } from '../cards/card-detail-drawer/card-detail-drawer.component';
import { FormControl, FormGroup } from '@angular/forms';
import { PageLayoutMode } from '../_models/page-layout-mode';
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
interface RelatedSeris {
series: Series;
@ -50,6 +52,12 @@ enum TabID {
Chapters = 4
}
interface StoryLineItem {
chapter?: Chapter;
volume?: Volume;
isChapter: boolean;
}
@Component({
selector: 'app-series-detail',
templateUrl: './series-detail.component.html',
@ -57,6 +65,8 @@ enum TabID {
})
export class SeriesDetailComponent implements OnInit, OnDestroy {
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
/**
* Series Id. Set at load before UI renders
*/
@ -65,6 +75,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
volumes: Volume[] = [];
chapters: Chapter[] = [];
storyChapters: Chapter[] = [];
storylineItems: StoryLineItem[] = [];
libraryId = 0;
isAdmin = false;
hasDownloadingRole = false;
@ -101,6 +112,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
actionInProgress: boolean = false;
itemSize: number = 10; // when 10 done, 16 loads
/**
* Track by function for Volume to tell when to refresh card data
*/
@ -110,6 +123,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
trackByRelatedSeriesIdentiy = (index: number, item: RelatedSeris) => `${item.series.name}_${item.series.libraryId}_${item.series.pagesRead}_${item.relation}`;
trackByStoryLineIdentity = (index: number, item: StoryLineItem) => {
if (item.isChapter) {
return this.trackByChapterIdentity(index, item!.chapter!)
}
return this.trackByVolumeIdentity(index, item!.volume!);
};
/**
* Are there any related series
@ -120,6 +139,20 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
relations: Array<RelatedSeris> = [];
sortingOptions: Array<{value: string, text: string}> = [
{value: 'Storyline', text: 'Storyline'},
{value: 'Release', text: 'Release'},
{value: 'Added', text: 'Added'},
];
renderMode: PageLayoutMode = PageLayoutMode.Cards;
pageExtrasGroup = new FormGroup({
'sortingOption': new FormControl(this.sortingOptions[0].value, []),
'renderMode': new FormControl(this.renderMode, []),
});
isAscendingSort: boolean = false; // TODO: Get this from User preferences
bulkActionCallback = (action: Action, data: any) => {
if (this.series === undefined) {
return;
@ -167,22 +200,27 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
private onDestroy: Subject<void> = new Subject();
get LibraryType(): typeof LibraryType {
get LibraryType() {
return LibraryType;
}
get MangaFormat(): typeof MangaFormat {
get MangaFormat() {
return MangaFormat;
}
get TagBadgeCursor(): typeof TagBadgeCursor {
get TagBadgeCursor() {
return TagBadgeCursor;
}
get TabID(): typeof TabID {
get TabID() {
return TabID;
}
get PageLayoutMode() {
return PageLayoutMode;
}
constructor(private route: ActivatedRoute, private seriesService: SeriesService,
private router: Router, public bulkSelectionService: BulkSelectionService,
private modalService: NgbModal, public readerService: ReaderService,
@ -200,10 +238,28 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
this.renderMode = user.preferences.globalPageLayoutMode;
this.pageExtrasGroup.get('renderMode')?.setValue(this.renderMode);
}
});
}
onScroll(): void {
const tabs = document.querySelector('.nav-tabs') as HTMLElement | null;
const main = document.querySelector('.main-container') as HTMLElement | null;
const content = document.querySelector('.tab-content') as HTMLElement | null;
let mainOffset = main!.offsetTop;
let tabOffset = tabs!.offsetTop;
let contentOffset = content!.offsetTop;
let mainScrollPos = main!.scrollTop;
if (!document.querySelector('.nav-tabs.fixed') && (tabOffset - mainOffset) <= mainScrollPos) {
tabs!.classList.add("fixed");
} else if (document.querySelector('.nav-tabs.fixed') && mainScrollPos <= (contentOffset - mainOffset)) {
tabs!.classList.remove("fixed");
}
}
ngOnInit(): void {
const routeId = this.route.snapshot.paramMap.get('seriesId');
const libraryId = this.route.snapshot.paramMap.get('libraryId');
@ -223,7 +279,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent;
if (seriesCoverUpdatedEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId);
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId)); // NOTE: Is this needed as cover update will update the image for us
}
}
});
@ -232,6 +287,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(this.seriesId);
this.loadSeries(this.seriesId);
this.pageExtrasGroup.get('renderMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((val: PageLayoutMode) => {
this.renderMode = val;
});
}
ngOnDestroy() {
@ -290,6 +349,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => this.actionInProgress = false);
break;
case (Action.Download):
if (this.downloadInProgress) return;
this.downloadSeries();
break;
default:
break;
}
@ -358,12 +421,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
this.setContinuePoint();
forkJoin([
this.libraryService.getLibraryType(this.libraryId),
this.seriesService.getSeries(seriesId)
]).subscribe(results => {
this.libraryType = results[0];
this.series = results[1];
forkJoin({
libType: this.libraryService.getLibraryType(this.libraryId),
series: this.seriesService.getSeries(seriesId)
}).subscribe(results => {
this.libraryType = results.libType;
console.log('library type: ', this.libraryType);
this.series = results.series;
this.createHTML();
@ -371,6 +435,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.seriesActions.push({action: Action.Download, callback: this.seriesActions[0].callback, requiresAdmin: false, title: 'Download'});
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
@ -401,6 +467,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.chapters = detail.chapters;
this.volumes = detail.volumes;
this.storyChapters = detail.storylineChapters;
this.storylineItems = [];
const v = this.volumes.map(v => {
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
});
this.storylineItems.push(...v);
const c = this.storyChapters.map(c => {
return {volume: undefined, chapter: c, isChapter: true} as StoryLineItem;
});
this.storylineItems.push(...c);
this.updateSelectedTab();
this.isLoading = false;
@ -530,9 +606,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.toastr.error('There are no chapters to this volume. Cannot read.');
return;
}
// NOTE: When selecting a volume, we might want to ask the user which chapter they want or an "Automatic" option. For Volumes
// made up of lots of chapter files, it makes it more versitile. The modal can have pages read / pages with colored background
// to help the user make a good choice.
// If user has progress on the volume, load them where they left off
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
@ -573,15 +646,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
this.loadSeries(this.seriesId);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId));
}
}
});
}
async promptToReview() {
// TODO: After a review has been set, we might just want to show an edit icon next to star rating which opens the review, instead of prompting each time.
const shouldPrompt = this.isNullOrEmpty(this.series.userReview);
const config = new ConfirmConfig();
config.header = 'Confirm';
@ -631,15 +701,15 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
}
formatChapterTitle(chapter: Chapter) {
return this.utilityService.formatChapterName(this.libraryType, true, true) + chapter.range;
}
updateSortOrder() {
this.isAscendingSort = !this.isAscendingSort;
// if (this.filter.sortOptions === null) {
// this.filter.sortOptions = {
// isAscending: this.isAscendingSort,
// sortField: SortField.SortName
// }
// }
formatVolumeTitle(volume: Volume) {
if (this.libraryType === LibraryType.Book) {
return volume.name;
}
return 'Volume ' + volume.name;
// this.filter.sortOptions.isAscending = this.isAscendingSort;
}
}

View file

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesDetailRoutingModule } from './series-detail-routing.module';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SeriesDetailComponent } from './series-detail.component';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { ReviewSeriesModalComponent } from './review-series-modal/review-series-modal.component';
@ -17,7 +17,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
declarations: [
SeriesDetailComponent,
ReviewSeriesModalComponent,
SeriesMetadataDetailComponent
SeriesMetadataDetailComponent,
],
imports: [
CommonModule,
@ -26,6 +26,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
NgbCollapseModule, // Series Metadata
NgbNavModule,
NgbRatingModule,
NgbTooltipModule, // Series Detail, Extras Drawer
TypeaheadModule,
PipeModule,

View file

@ -2,108 +2,7 @@
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
</div>
<!-- This first row will have random information about the series-->
<div class="row g-0 mb-4 mt-3">
<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)="goTo(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="series">
<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)="goTo(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>
<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)="goTo(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>
<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)="goTo(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="readingTimeLeft.hasProgress && 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>
<div class="row g-0" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
<div class="col-md-4">
@ -300,4 +199,7 @@
<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}} me-1" aria-controls="extended-series-metadata"></i>
See {{isCollapsed ? 'More' : 'Less'}}
</a>
</div>
</div>
<!-- This first row will have random information about the series-->
<app-series-info-cards [series]="series" [seriesMetadata]="seriesMetadata" (goTo)="handleGoTo($event)" [hasReadingProgress]="hasReadingProgress"></app-series-info-cards>

View file

@ -1,7 +1,7 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { Router } from '@angular/router';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { MAX_WORDS_PER_HOUR, MIN_WORDS_PER_HOUR, MIN_PAGES_PER_MINUTE, MAX_PAGES_PER_MINUTE, ReaderService } from 'src/app/_services/reader.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { UtilityService } from '../../shared/_services/utility.service';
@ -20,6 +20,7 @@ import { MetadataService } from '../../_services/metadata.service';
export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
@Input() seriesMetadata!: SeriesMetadata;
@Input() hasReadingProgress: boolean = false;
/**
* Reading lists with a connection to the Series
*/
@ -29,8 +30,8 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
isCollapsed: boolean = true;
hasExtendedProperites: boolean = false;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
// readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
// readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
/**
* Html representation of Series Summary
@ -67,11 +68,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}
if (this.series !== null) {
this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
this.readerService.getTimeToRead(this.series.id).subscribe((time) => this.readingTime = time);
}
}
ngOnInit(): void {
@ -81,6 +77,10 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
this.isCollapsed = !this.isCollapsed;
}
handleGoTo(event: {queryParamName: FilterQueryParam, filter: any}) {
this.goTo(event.queryParamName, event.filter);
}
goTo(queryParamName: FilterQueryParam, filter: any) {
let params: any = {};
params[queryParamName] = filter;