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("Asterix - HS - Les 12 travaux d'Astérix", true)]
[InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)]
[InlineData("laughs", false)] [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) public void ParseComicSpecialTest(string input, bool expected)
{ {
Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseComicSpecial(input))); 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) public async Task<ActionResult<ChapterDto>> GetContinuePoint(int seriesId)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _readerService.GetContinuePoint(seriesId, userId)); 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> /// <summary>
/// Returns a list of bookmarked pages for a given Chapter /// Returns a list of bookmarked pages for a given Chapter
/// </summary> /// </summary>

View File

@ -12,6 +12,7 @@ public interface IAppUserProgressRepository
Task<int> CleanupAbandonedChapters(); Task<int> CleanupAbandonedChapters();
Task<bool> UserHasProgress(LibraryType libraryType, int userId); Task<bool> UserHasProgress(LibraryType libraryType, int userId);
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId); Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
} }
public class AppUserProgressRepository : IAppUserProgressRepository public class AppUserProgressRepository : IAppUserProgressRepository
@ -76,6 +77,12 @@ public class AppUserProgressRepository : IAppUserProgressRepository
.AnyAsync(); .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) public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
{ {
return await _context.AppUserProgresses 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. // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle.
new Regex( 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), 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); 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) { getCurrentChapter(seriesId: number) {
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId); return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
} }

View File

@ -62,7 +62,7 @@
<div> <div>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations> <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"> <li [ngbNavItem]="1" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a> <a ngbNavLink>Specials</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@ -75,8 +75,8 @@
</div> </div>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="2" *ngIf="hasNonSpecialVolumeChapters"> <li [ngbNavItem]="2" *ngIf="libraryType !== LibraryType.Book && (hasNonSpecialVolumeChapters || hasNonSpecialNonVolumeChapters)">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a> <a ngbNavLink>Storyline</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row no-gutters"> <div class="row no-gutters">
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity"> <div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
@ -84,6 +84,30 @@
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset" [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> [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>
<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 chapter of chapters; let idx = index; trackBy: trackByChapterIdentity"> <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)" <app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="formatChapterTitle(chapter)" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset" [imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"

View File

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