mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
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:
parent
42026aca09
commit
21b89a5386
@ -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)));
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
};
|
||||
|
||||
|
@ -103,6 +103,10 @@ export class ReaderService {
|
||||
return this.httpClient.get<number>(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '¤tChapterId=' + 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);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user