Series Detail Enhancements (#983)

* Series Detail Enhancements

# Added
- Added: Volume tab for `Comic` Library Types
- Added: Storyline tab for `Comic` and `Manga` library types. This will show Volumes and Chapters together sorted in order.

# Changed
- Changed: Changed `Chapters/Issues` to show all chapters or issues regardless of if they are in a volume for both `Manga` and `Comic` library types

* Removed 3 loops to speed up load time

* Refactored some library type checks. Reset selection on nav change.

* Refactored hasReadingProgress for a series to the backend and further optimized the series detail page.

* Fixed up the regex for "Annual" special case and added unit tests.

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
Robbie Davis 2022-01-24 12:02:44 -05:00 committed by GitHub
parent 42026aca09
commit 21b89a5386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 101 additions and 27 deletions

View File

@ -186,6 +186,10 @@ namespace API.Tests.Parser
[InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)]
[InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)]
[InlineData("laughs", false)]
[InlineData("Annual Days of Summer", false)]
[InlineData("Adventure Time 2013 Annual #001 (2013)", true)]
[InlineData("Adventure Time 2013_Annual_#001 (2013)", true)]
[InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)]
public void ParseComicSpecialTest(string input, bool expected)
{
Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseComicSpecial(input)));

View File

@ -365,10 +365,21 @@ namespace API.Controllers
public async Task<ActionResult<ChapterDto>> GetContinuePoint(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _readerService.GetContinuePoint(seriesId, userId));
}
/// <summary>
/// Returns if the user has reading progress on the Series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("has-progress")]
public async Task<ActionResult<ChapterDto>> HasProgress(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId));
}
/// <summary>
/// Returns a list of bookmarked pages for a given Chapter
/// </summary>

View File

@ -12,6 +12,7 @@ public interface IAppUserProgressRepository
Task<int> CleanupAbandonedChapters();
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
}
public class AppUserProgressRepository : IAppUserProgressRepository
@ -76,6 +77,12 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.AnyAsync();
}
public async Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId)
{
return await _context.AppUserProgresses
.AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId);
}
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
{
return await _context.AppUserProgresses

View File

@ -484,7 +484,7 @@ namespace API.Parser
{
// All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
new Regex(
@"(?<Special>Specials?|OneShot|One\-Shot|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))",
@"(?<Special>Specials?|OneShot|One\-Shot|\d.+?(\W|_|-)Annual|Annual(\W|_|-)\d.+?|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))",
MatchOptions, RegexTimeout),
};

View File

@ -103,6 +103,10 @@ export class ReaderService {
return this.httpClient.get<number>(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '&currentChapterId=' + currentChapterId);
}
hasSeriesProgress(seriesId: number) {
return this.httpClient.get<boolean>(this.baseUrl + 'reader/has-progress?seriesId=' + seriesId);
}
getCurrentChapter(seriesId: number) {
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
}

View File

@ -62,7 +62,7 @@
<div>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav-tabs nav-pills" [destroyOnHide]="false">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav-tabs nav-pills" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<li [ngbNavItem]="1" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>
@ -75,15 +75,39 @@
</div>
</ng-template>
</li>
<li [ngbNavItem]="2" *ngIf="hasNonSpecialVolumeChapters">
<li [ngbNavItem]="2" *ngIf="libraryType !== LibraryType.Book && (hasNonSpecialVolumeChapters || hasNonSpecialNonVolumeChapters)">
<a ngbNavLink>Storyline</a>
<ng-template ngbNavContent>
<div class="row no-gutters">
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="formatVolumeTitle(volume)" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[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>
</div>
<div *ngFor="let chapter of storyChapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="formatChapterTitle(chapter)" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="3" *ngIf="libraryType !== LibraryType.Comic && hasNonSpecialVolumeChapters">
<a ngbNavLink>Volumes</a>
<ng-template ngbNavContent>
<div class="row no-gutters">
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="formatVolumeTitle(volume)" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[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>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="4" *ngIf="hasNonSpecialNonVolumeChapters">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<ng-template ngbNavContent>
<div class="row no-gutters">
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="formatVolumeTitle(volume)" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[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>
</div>
<div *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="formatChapterTitle(chapter)" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"

View File

@ -1,7 +1,7 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbNavChangeEvent, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
@ -43,6 +43,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
series!: Series;
volumes: Volume[] = [];
chapters: Chapter[] = [];
storyChapters: Chapter[] = [];
libraryId = 0;
isAdmin = false;
hasDownloadingRole = false;
@ -61,7 +62,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
hasSpecials = false;
specials: Array<Chapter> = [];
activeTabId = 2;
hasNonSpecialVolumeChapters = true;
hasNonSpecialVolumeChapters = false;
hasNonSpecialNonVolumeChapters = false;
userReview: string = '';
libraryType: LibraryType = LibraryType.Manga;
@ -223,6 +225,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
}
onNavChange(event: NgbNavChangeEvent) {
this.bulkSelectionService.deselectAll();
}
handleSeriesActionCallback(action: Action, series: Series) {
this.actionInProgress = true;
switch(action) {
@ -336,14 +342,14 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
loadSeries(seriesId: number) {
this.coverImageOffset = 0;
this.seriesService.getMetadata(seriesId).subscribe(metadata => this.seriesMetadata = metadata);
forkJoin([
this.libraryService.getLibraryType(this.libraryId),
this.seriesService.getMetadata(seriesId),
this.seriesService.getSeries(seriesId)
]).subscribe(results => {
this.libraryType = results[0];
this.seriesMetadata = results[1];
this.series = results[2];
this.series = results[1];
this.createHTML();
@ -357,17 +363,19 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
this.chapters = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
this.volumes = volumes.sort(this.utilityService.sortVolumes);
this.volumes = volumes; // volumes are already be sorted in the backend
const vol0 = this.volumes.filter(v => v.number === 0);
this.storyChapters = vol0.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
this.chapters = volumes.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters).filter(c => !c.isSpecial || isNaN(parseInt(c.range, 10)));
this.setContinuePoint();
const vol0 = this.volumes.filter(v => v.number === 0);
this.hasSpecials = vol0.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters).filter(c => c.isSpecial || isNaN(parseInt(c.range, 10))).length > 0 ;
const specials = this.storyChapters.filter(c => c.isSpecial || isNaN(parseInt(c.range, 10)));
this.hasSpecials = specials.length > 0
if (this.hasSpecials) {
this.specials = vol0.map(v => v.chapters || [])
.flat()
.filter(c => c.isSpecial || isNaN(parseInt(c.range, 10)))
this.specials = specials
.map(c => {
c.title = this.utilityService.cleanSpecialTitle(c.title);
c.range = this.utilityService.cleanSpecialTitle(c.range);
@ -375,14 +383,30 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
}
if (this.volumes.filter(v => v.number !== 0).length === 0 && this.chapters.filter(c => !c.isSpecial).length === 0 && this.specials.length > 0) {
this.activeTabId = 1;
this.hasNonSpecialVolumeChapters = false;
// This shows Chapters/Issues tab
// If this has chapters that are not specials
if (this.chapters.filter(c => !c.isSpecial).length > 0) {
if (this.utilityService.formatChapterName(this.libraryType) == 'Book') {
this.activeTabId = 4;
}
this.hasNonSpecialNonVolumeChapters = true;
}
// This shows Volumes tab
if (this.volumes.filter(v => v.number !== 0).length !== 0) {
if (this.utilityService.formatChapterName(this.libraryType) == 'Book') {
this.activeTabId = 3;
}
this.hasNonSpecialVolumeChapters = true;
}
// If an update occured and we were on specials, re-activate Volumes/Chapters
if (!this.hasSpecials && this.activeTabId != 2) {
this.activeTabId = 2;
if (!this.hasSpecials && !this.hasNonSpecialVolumeChapters && this.activeTabId != 2) {
this.activeTabId = 3;
}
if (this.hasSpecials) {
this.activeTabId = 1;
}
this.isLoading = false;
@ -397,7 +421,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
setContinuePoint() {
this.hasReadingProgress = this.volumes.filter(v => v.pagesRead > 0).length > 0 || this.chapters.filter(c => c.pagesRead > 0).length > 0;
this.readerService.hasSeriesProgress(this.series.id).subscribe(hasProgress => this.hasReadingProgress = hasProgress);
this.readerService.getCurrentChapter(this.series.id).subscribe(chapter => this.currentlyReadingChapter = chapter);
}