Drawers, Estimated Reading Time, Korean Parsing Support (#1297)

* Started building out idea around detail drawer. Need code from word count to continue

* Fixed the logic for caluclating time to read on comics

* Adding styles

* more styling fixes

* Cleaned up the styles a bit more so it's at least functional. Not sure on the feature, might abandon.

* Pulled Robbie's changes in and partially migrated them to the drawer.

* Add offset overrides for offcanvas so it takes our header into account

* Implemented a basic time left to finish the series (or at least what's in Kavita). Rough around the edges.

* Cleaned up the drawer code.

* Added Quick Catch ups to recommended page. Updated the timeout for scan tasks to ensure we don't run 2 at the same time.

* Quick catchups implemented

* Added preliminary support for Korean filename parsing. Reduced an array alloc that is called many thousands of times per scan.

* Fixing drawer overflow

* Fixed a calculation bug with average reading time.

* Small spacing changes to drawer

* Don't show estimated reading time if the user hasn't read anything

* Bump eventsource from 1.1.1 to 2.0.2 in /UI/Web

Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.1 to 2.0.2.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.1.1...v2.0.2)

---
updated-dependencies:
- dependency-name: eventsource
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Added image to series detail drawer

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Joseph Milazzo 2022-05-27 09:08:54 -05:00 committed by GitHub
parent d796bcdc0a
commit 63475722ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 883 additions and 144 deletions

View File

@ -69,6 +69,8 @@ namespace API.Tests.Parser
[InlineData("幽游白书完全版 第03卷 天下", "3")]
[InlineData("阿衰online 第1册", "1")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")]
[InlineData("63권#200", "63")]
[InlineData("시즌34삽화2", "34")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
@ -250,7 +252,7 @@ namespace API.Tests.Parser
[InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")]
[InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "25")]
[InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));

View File

@ -149,4 +149,18 @@ public class MetadataController : BaseApiController
IsoCode = c.IetfLanguageTag
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
}
/// <summary>
/// Returns summary for the chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
{
if (chapterId <= 0) return BadRequest("Chapter does not exist");
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest("Chapter does not exist");
return Ok(chapter.Summary);
}
}

View File

@ -8,6 +8,7 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
@ -627,5 +628,46 @@ namespace API.Controllers
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
}
/// <summary>
/// For the current user, returns an estimate on how long it would take to finish reading the series.
/// </summary>
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("time-left")]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
// Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId);
if (series.Format == MangaFormat.Epub)
{
var chapters =
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList());
// Word count
var progressCount = chapters.Sum(c => c.WordCount);
var wordsLeft = series.WordCount - progressCount;
return Ok(new HourEstimateRangeDto()
{
MinHours = (int) Math.Round((wordsLeft / ReaderService.MinWordsPerHour)),
MaxHours = (int) Math.Round((wordsLeft / ReaderService.MaxWordsPerHour)),
AvgHours = (int) Math.Round((wordsLeft / ReaderService.AvgWordsPerHour)),
HasProgress = progressCount > 0
});
}
var progressPageCount = progress.Sum(p => p.PagesRead);
var pagesLeft = series.Pages - progressPageCount;
return Ok(new HourEstimateRangeDto()
{
MinHours = (int) Math.Round((pagesLeft / ReaderService.MinPagesPerMinute / 60F)),
MaxHours = (int) Math.Round((pagesLeft / ReaderService.MaxPagesPerMinute / 60F)),
AvgHours = (int) Math.Round((pagesLeft / ReaderService.AvgPagesPerMinute / 60F)),
HasProgress = progressPageCount > 0
});
}
}
}

View File

@ -19,7 +19,7 @@ public class RecommendedController : BaseApiController
/// <summary>
/// Quick Reads are series that are less than 2K pages in total.
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <returns></returns>
@ -35,6 +35,24 @@ public class RecommendedController : BaseApiController
return Ok(series);
}
/// <summary>
/// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpGet("quick-catchup-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
/// </summary>

View File

@ -394,6 +394,8 @@ namespace API.Controllers
return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId));
}
[Authorize(Policy="RequireAdminRole")]
[HttpPost("update-related")]
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.Entities.Enums;
namespace API.DTOs
{
@ -61,9 +62,5 @@ namespace API.DTOs
/// </summary>
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View File

@ -47,6 +47,10 @@ namespace API.DTOs.Metadata
/// Total number of issues for the series
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View File

@ -0,0 +1,24 @@
namespace API.DTOs.Reader;
/// <summary>
/// A range of time to read a selection (series, chapter, etc)
/// </summary>
public class HourEstimateRangeDto
{
/// <summary>
/// Min hours to read the selection
/// </summary>
public int MinHours { get; set; } = 1;
/// <summary>
/// Max hours to read the selection
/// </summary>
public int MaxHours { get; set; } = 1;
/// <summary>
/// Estimated average hours to read the selection
/// </summary>
public int AvgHours { get; set; } = 1;
/// <summary>
/// Does the user have progress on the range this represents
/// </summary>
public bool HasProgress { get; set; } = false;
}

View File

@ -17,6 +17,7 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -113,6 +114,7 @@ public interface ISeriesRepository
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
@ -1131,7 +1133,10 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series
.Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing)
.AsSplitQuery()
@ -1141,6 +1146,30 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
.Select(p => p.SeriesId)
.Distinct();
var query = _context.Series
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
/// <summary>
/// Returns all library ids for a user
/// </summary>

View File

@ -110,6 +110,22 @@ namespace API.Parser
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n,
new Regex(
@"시즌(?<Volume>\d+\-?\d+)",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"(?<Volume>\d+(\-|~)?\d+?)시즌",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"시즌(?<Volume>\d+(\-|~)?\d+?)",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaSeriesRegex = new[]
@ -340,6 +356,18 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册
new Regex(
@"第(?<Volume>\d+)(卷|册)",
MatchOptions, RegexTimeout),
// Chinese Volume: 卷n -> Volume n, 册n -> Volume n
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] ComicChapterRegex = new[]
@ -398,11 +426,7 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)-(chapter-)?(?<Chapter>\d+)",
MatchOptions, RegexTimeout),
// Cyberpunk 2077 - Your Voice 01
// new Regex(
// @"^(?<Series>.+?\s?-\s?(?:.+?))(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)$",
// MatchOptions,
// RegexTimeout),
};
private static readonly Regex[] ReleaseGroupRegex = new[]
@ -461,7 +485,10 @@ namespace API.Parser
new Regex(
@"第(?<Chapter>\d+)话",
MatchOptions, RegexTimeout),
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
new Regex(
@"제?(?<Chapter>\d+\.?\d+)(화|장)",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaEditionRegex = {
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
@ -525,11 +552,13 @@ namespace API.Parser
MatchOptions, RegexTimeout
);
private static readonly ImmutableArray<string> FormatTagSpecialKeyowrds = ImmutableArray.Create(
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
"GN", "FCBD");
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
public static MangaFormat ParseFormat(string filePath)
{
if (IsArchive(filePath)) return MangaFormat.Archive;
@ -916,8 +945,8 @@ namespace API.Parser
public static string RemoveLeadingZeroes(string title)
{
var ret = title.TrimStart(new[] { '0' });
return ret == string.Empty ? "0" : ret;
var ret = title.TrimStart(LeadingZeroesTrimChars);
return string.IsNullOrEmpty(ret) ? "0" : ret;
}
public static bool IsArchive(string filePath)
@ -1060,7 +1089,7 @@ namespace API.Parser
/// <returns></returns>
public static bool HasComicInfoSpecial(string comicInfoFormat)
{
return FormatTagSpecialKeyowrds.Contains(comicInfoFormat);
return FormatTagSpecialKeywords.Contains(comicInfoFormat);
}
}
}

View File

@ -196,7 +196,7 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
{

View File

@ -38,6 +38,14 @@ public class ReaderService : IReaderService
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public const float MinWordsPerHour = 10260F;
public const float MaxWordsPerHour = 30000F;
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
public const float MinPagesPerMinute = 3.33F;
public const float MaxPagesPerMinute = 2.75F;
public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F;
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub)
{
_unitOfWork = unitOfWork;

View File

@ -40,7 +40,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
_cacheHelper = cacheHelper;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
{

View File

@ -63,7 +63,7 @@ public class ScannerService : IScannerService
_wordCountAnalyzerService = wordCountAnalyzerService;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token)
{
@ -247,7 +247,7 @@ public class ScannerService : IScannerService
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60 * 4)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries()
{
@ -267,7 +267,7 @@ public class ScannerService : IScannerService
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
/// </summary>
/// <param name="libraryId"></param>
[DisableConcurrentExecution(360)]
[DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId)
{
@ -470,6 +470,7 @@ public class ScannerService : IScannerService
foreach (var series in duplicateSeries)
{
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
}
continue;

View File

@ -2424,12 +2424,22 @@
"fetch-cookie": "^0.11.0",
"node-fetch": "^2.6.1",
"ws": "^7.4.5"
},
"dependencies": {
"eventsource": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz",
"integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==",
"requires": {
"original": "^1.0.0"
}
}
}
},
"@ng-bootstrap/ng-bootstrap": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0.tgz",
"integrity": "sha512-XWf/CsP1gH0aev7Mtsldtj0DPPFdTrJpSiyjzLFS29gU1ZuDlJz6OKthgUDxZoua6uNPAzaGMc0A20T+reMfRw==",
"version": "12.1.2",
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.1.2.tgz",
"integrity": "sha512-p27c+mYVdHiJMYrj5hwClVJxLdiZxafAqlbw1sdJh2xJ1rGOe+H/kCf5YDRbhlHqRN+34Gr0RQqIUeD1I2V8hg==",
"requires": {
"tslib": "^2.3.0"
}
@ -5757,12 +5767,9 @@
"dev": true
},
"eventsource": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz",
"integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==",
"requires": {
"original": "^1.0.0"
}
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="
},
"execa": {
"version": "5.1.1",

View File

@ -28,12 +28,12 @@
"@angular/router": "~13.2.2",
"@fortawesome/fontawesome-free": "^6.0.0",
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
"@popperjs/core": "^2.11.2",
"@types/file-saver": "^2.0.5",
"bootstrap": "^5.1.2",
"bowser": "^2.11.0",
"eventsource": "^1.1.1",
"eventsource": "^2.0.2",
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",

View File

@ -17,6 +17,8 @@ export interface ChapterMetadata {
summary: string;
count: number;
totalCount: number;
wordCount: number;
genres: Array<Genre>;

View File

@ -0,0 +1,6 @@
export interface HourEstimateRange{
minHours: number;
maxHours: number;
avgHours: number;
hasProgress: boolean;
}

View File

@ -97,4 +97,8 @@ export class MetadataService {
}
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
}
getChapterSummary(chapterId: number) {
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'});
}
}

View File

@ -4,10 +4,15 @@ import { environment } from 'src/environments/environment';
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter';
import { HourEstimateRange } from '../_models/hour-estimate-range';
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/page-bookmark';
import { ProgressBookmark } from '../_models/progress-bookmark';
import { Volume } from '../_models/volume';
export const MAX_WORDS_PER_HOUR = 30_000;
export const MIN_WORDS_PER_HOUR = 10_260;
export const MAX_PAGES_PER_MINUTE = 2.75;
export const MIN_PAGES_PER_MINUTE = 3.33;
@Injectable({
providedIn: 'root'
@ -124,6 +129,10 @@ export class ReaderService {
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
}
getTimeLeft(seriesId: number) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
}
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/

View File

@ -22,6 +22,13 @@ export class RecommendationService {
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
}
getQuickCatchupReads(libraryId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'recommended/quick-catchup-reads?libraryId=' + libraryId, {observe: 'response', params})
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
}
getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);

View File

@ -3,7 +3,7 @@ import {DOCUMENT, Location} from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, fromEvent, of, Subject } from 'rxjs';
import { catchError, debounceTime, take, takeUntil, tap } from 'rxjs/operators';
import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators';
import { Chapter } from 'src/app/_models/chapter';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
@ -27,7 +27,6 @@ import { User } from 'src/app/_models/user';
import { ThemeService } from 'src/app/_services/theme.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { PAGING_DIRECTION } from 'src/app/manga-reader/_models/reader-enums';
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
enum TabID {

View File

@ -0,0 +1,248 @@
<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>
</h5>
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body pb-3">
<div class="d-flex">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="vertical" style="max-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>General</a>
<ng-template ngbNavContent>
<div class="container-fluid" style="overflow: auto">
<div class="row g-0">
<div class="d-none d-md-block col-md-2 col-lg-1">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</div>
<div *ngIf="summary$ | async as summary" class="col-md-10 col-lg-11">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
</div>
<div class="row g-0 mt-4 mb-3">
<ng-container *ngIf="chapter.pages">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
{{chapter.pages}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapterMetadata !== undefined && chapterMetadata.releaseDate">
<div class="col-auto mb-2">
<app-icon-and-title [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 [clickable]="false" fontClasses="fa-regular fa-clock">
{{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hour{{minHoursToRead > 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 [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 [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
{{ageRating}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
<!-- 2 rows to show some tags-->
<ng-container *ngIf="chapterMetadata !== undefined">
<div class="row g-0 mb-2">
<div class="col-md-6 col-sm-12">
<h6>Authors/Writers</h6>
<ng-container *ngIf="chapterMetadata.writers.length > 0; else noBadges">
<app-badge-expander [items]="chapterMetadata.writers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</ng-container>
</div>
<div class="col-md-6 col-sm-12">
<h6>Genres</h6>
<ng-container *ngIf="chapterMetadata.genres.length > 0; else noBadges">
<app-badge-expander [items]="chapterMetadata.genres">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</ng-container>
</div>
</div>
<div class="row g-0 mb-2">
<div class="col-md-6 col-sm-12">
<h6>Publisher</h6>
<ng-container *ngIf="chapterMetadata.publishers.length > 0; else noBadges">
<app-badge-expander [items]="chapterMetadata.publishers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</ng-container>
</div>
<div class="col-md-6 col-sm-12">
<h6>Tags</h6>
<ng-container *ngIf="chapterMetadata.tags.length > 0; else noBadges">
<app-badge-expander [items]="chapterMetadata.tags">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</ng-container>
</div>
</div>
<ng-template #noBadges>
Not defined
</ng-template>
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Metadata]">
<a ngbNavLink>{{tabs[TabID.Metadata].title}}</a>
<ng-template ngbNavContent>
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
</ng-template>
</li>
<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>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Files]">
<a ngbNavLink>{{tabs[TabID.Files].title}}</a>
<ng-template ngbNavContent>
<div class="row g-0 mb-3">
<ng-container *ngIf="chapter.created && chapter.created !== '' && (chapter.created | date: 'shortDate') !== '1/1/01'">
<div class="col-auto">
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
Created: {{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 [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
ID: {{data.id}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span >
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
</span>
</span>
<ng-template #specialHeader>Files</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
</div>
</li>
</ul>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
</div>

View File

@ -0,0 +1,16 @@
.hide-if-empty:empty {
display: none !important;
}
.offcanvas-body {
overflow: auto;
}
.offcanvas-header {
padding: 1rem 1rem 0;
}
.tab-content {
overflow: auto;
height: calc(40vh - 62px); // drawer height - offcanvas heading height
}

View File

@ -0,0 +1,263 @@
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 { Observable, of, take } from 'rxjs';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { LibraryType } from 'src/app/_models/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PersonRole } from 'src/app/_models/person';
import { Volume } from 'src/app/_models/volume';
import { AccountService } from 'src/app/_services/account.service';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { MAX_PAGES_PER_MINUTE, MAX_WORDS_PER_HOUR, MIN_PAGES_PER_MINUTE, MIN_WORDS_PER_HOUR, ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service';
enum TabID {
General = 0,
Metadata = 1,
Cover = 2,
Files = 3
}
@Component({
selector: 'app-card-detail-drawer',
templateUrl: './card-detail-drawer.component.html',
styleUrls: ['./card-detail-drawer.component.scss']
})
export class CardDetailDrawerComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input() data!: Volume | Chapter;
/**
* If this is a volume, this will be first chapter for said volume.
*/
chapter!: Chapter;
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> = [];
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga;
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}];
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
ageRating!: string;
summary$: Observable<string> = of('');
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
get MangaFormat() {
return MangaFormat;
}
get Breakpoint() {
return Breakpoint;
}
get PersonRole() {
return PersonRole;
}
get LibraryType() {
return LibraryType;
}
get TabID() {
return TabID;
}
constructor(public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService,
public activeOffcanvas: NgbActiveOffcanvas) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
if (this.chapter.files[0].format === MangaFormat.EPUB && this.chapterMetadata.wordCount > 0) {
this.minHoursToRead = parseInt(Math.round(this.chapterMetadata.wordCount / MAX_WORDS_PER_HOUR) + '', 10) || 1;
this.maxHoursToRead = parseInt(Math.round(this.chapterMetadata.wordCount / MIN_WORDS_PER_HOUR) + '', 10) || 1;
} else if (this.chapter.files[0].format !== MangaFormat.EPUB) {
this.minHoursToRead = parseInt(Math.round((this.chapter.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10) || 1;
this.maxHoursToRead = parseInt(Math.round((this.chapter.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10) || 1;
}
});
if (this.isChapter) {
this.summary$ = this.metadataService.getChapterSummary(this.data.id);
} else {
this.summary$ = this.metadataService.getChapterSummary(this.utilityService.asVolume(this.data).chapters[0].id);
}
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
if (!this.accountService.hasAdminRole(user)) {
this.tabs.find(s => s.title === 'Cover')!.disabled = true;
}
}
});
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
});
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
if (this.isChapter) {
this.chapters.push(this.data as Chapter);
} else if (!this.isChapter) {
this.chapters.push(...(this.data as Volume).chapters);
}
// TODO: Move this into the backend
this.chapters.sort(this.utilityService.sortChapters);
this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id));
// Try to show an approximation of the reading order for files
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
}
close() {
this.activeOffcanvas.close({coverImageUpdate: this.coverImageUpdate});
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
performAction(action: ActionItem<any>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action.action, chapter);
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
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;
});
}
}
markChapterAsRead(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ });
}
markChapterAsUnread(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ });
}
handleChapterActionCallback(action: Action, chapter: Chapter) {
switch (action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.AddToReadingList):
this.actionService.addChapterToReadingList(chapter, this.seriesId);
break;
default:
break;
}
}
readChapter(chapter: Chapter) {
if (chapter.pages === 0) {
this.toastr.error('There are no pages. Kavita was not able to read this archive.');
return;
}
if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]);
} else {
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
}
}
}

View File

@ -17,19 +17,6 @@
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<!-- <ng-container *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Mobile; else cardTemplate">
<div class="d-flex justify-content-center row g-0 mt-2 mb-2">
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
</ng-container> -->
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>

View File

@ -5,7 +5,7 @@ import { LibraryCardComponent } from './library-card/library-card.component';
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule, NgbOffcanvasModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxFileDropModule } from 'ngx-file-drop';
@ -22,6 +22,7 @@ import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapte
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';
@ -41,6 +42,7 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-
ChapterMetadataDetailComponent,
FileInfoComponent,
EditSeriesRelationComponent,
CardDetailDrawerComponent,
],
imports: [
CommonModule,
@ -59,13 +61,16 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-
NgbRatingModule,
NgbOffcanvasModule, // Series Detail, action of cards
NgbNavModule, //Series Detail
NgbPaginationModule, // CardDetailLayoutComponent
NgbDropdownModule,
NgbProgressbarModule,
NgxFileDropModule, // Cover Chooser
PipeModule // filter for BulkAddToCollectionComponent
PipeModule, // filter for BulkAddToCollectionComponent
SharedModule, // IconAndTitleComponent
],
exports: [
CardItemComponent,
@ -81,7 +86,9 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-
CardDetailsModalComponent,
BulkOperationsComponent,
ChapterMetadataDetailComponent,
EditSeriesRelationComponent
EditSeriesRelationComponent,
NgbOffcanvasModule
]
})
export class CardsModule { }

View File

@ -1,5 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { MetadataService } from 'src/app/_services/metadata.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { UtilityService } from 'src/app/shared/_services/utility.service';
@ -23,7 +22,7 @@ export class ChapterMetadataDetailComponent implements OnInit {
return LibraryType;
}
constructor(private metadataService: MetadataService, public utilityService: UtilityService) { }
constructor(public utilityService: UtilityService) { }
ngOnInit(): void {
this.roles = Object.keys(PersonRole).filter(role => /[0-9]/.test(role) === false);
@ -41,19 +40,4 @@ export class ChapterMetadataDetailComponent implements OnInit {
action.callback(action.action, chapter);
}
}
readChapter(chapter: Chapter) {
// if (chapter.pages === 0) {
// this.toastr.error('There are no pages. Kavita was not able to read this archive.');
// return;
// }
// if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) {
// this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]);
// } else {
// this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
// }
}
}

View File

@ -20,6 +20,14 @@
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickCatchups$ | async as quickCatchups">
<app-carousel-reel [items]="quickCatchups" title="Quick Catchups">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="highlyRated$ | async as highlyRated">
<app-carousel-reel [items]="highlyRated" title="Highly Rated">
<ng-template #carouselItem let-item let-position="idx">

View File

@ -16,6 +16,7 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy {
@Input() libraryId: number = 0;
quickReads$!: Observable<Series[]>;
quickCatchups$!: Observable<Series[]>;
highlyRated$!: Observable<Series[]>;
onDeck$!: Observable<Series[]>;
rediscover$!: Observable<Series[]>;
@ -36,6 +37,9 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy {
this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId)
.pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay());
this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId)
.pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay());
this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId)
.pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay());
@ -50,7 +54,7 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy {
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay());
});
this.all$ = merge(this.quickReads$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntil(this.onDestroy));
this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntil(this.onDestroy));
this.all$.subscribe(() => this.noData = false);
}

View File

@ -1,9 +1,9 @@
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
<div class="search">
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
<input #input [id]="id" type="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search"
>
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
<span class="visually-hidden">Loading...</span>

View File

@ -12,7 +12,9 @@ export class LanguageNamePipe implements PipeTransform {
transform(isoCode: string): Observable<string> {
return this.metadataService.getAllValidLanguages().pipe(map(lang => {
return lang.filter(l => l.isoCode === isoCode)[0].title;
const l = lang.filter(l => l.isoCode === isoCode);
if (l.length > 0) return l[0].title;
return '';
}));
}

View File

@ -61,28 +61,6 @@
<div *ngIf="seriesMetadata" class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"></app-series-metadata-detail>
</div>
<!-- <ng-container>
<div class="row g-0">
<div class="col-2">
<i class="fa-regular fa-file-lines" aria-hidden="true"></i>
{{series.pages}} Pages
</div>
|
<div class="col-2">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
1-2 Hours to Read
</div>
<ng-container *ngIf="utilityService.mangaFormat(series.format) === 'EPUB'">
|
<div class="col-2">
<i class="fa-regular fa-book-open" aria-hidden="true"></i>
10K Total Words
</div>
</ng-container>
</div>
</ng-container> -->
</div>
</div>

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, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap';
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
@ -35,6 +35,7 @@ import { SeriesService } from '../_services/series.service';
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';
interface RelatedSeris {
series: Series;
@ -196,7 +197,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService,
public imageSerivce: ImageService, private messageHub: MessageHubService,
private readingListService: ReadingListService, public navService: NavService
private readingListService: ReadingListService, public navService: NavService,
private offcanvasService: NgbOffcanvas
) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -560,12 +562,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
openViewInfo(data: Volume | Chapter) {
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' });
modalRef.componentInstance.data = data;
modalRef.componentInstance.parentName = this.series?.name;
modalRef.componentInstance.seriesId = this.series?.id;
modalRef.componentInstance.libraryId = this.series?.libraryId;
modalRef.closed.subscribe((result: {coverImageUpdate: boolean}) => {
const drawerRef = this.offcanvasService.open(CardDetailDrawerComponent, {position: 'bottom'});
drawerRef.componentInstance.data = data;
drawerRef.componentInstance.parentName = this.series?.name;
drawerRef.componentInstance.seriesId = this.series?.id;
drawerRef.componentInstance.libraryId = this.series?.libraryId;
drawerRef.closed.subscribe((result: {coverImageUpdate: boolean}) => {
if (result.coverImageUpdate) {
this.coverImageOffset += 1;
}

View File

@ -5,83 +5,100 @@
<!-- 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-auto">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title [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 m-2"></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-auto mb-2">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-auto mb-2">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title [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 m-2"></div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [clickable]="true" fontClasses="fa-solid fa-hourglass-empty" (click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
{{seriesMetadata.publicationStatus | publicationStatus}}
</app-icon-and-title>
</div>
<div class="vr m-2 mb-2"></div>
<div class="vr m-2 d-none d-lg-block"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [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 m-2"></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="col-auto mb-2">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<div class="col-auto mb-2">
<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 [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</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 [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages}} Pages
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-template>
<div class="vr d-none d-lg-block m-2"></div>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock">
{{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hour{{minHoursToRead > 1 ? 's' : ''}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0">
<div class="vr m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
<ng-container *ngIf="readingTimeLeft.hasProgress && readingTimeLeft.minHours !== 1 && readingTimeLeft.maxHours !== 1 && 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 [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

@ -1,5 +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 { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { UtilityService } from '../../shared/_services/utility.service';
@ -9,11 +11,6 @@ import { Series } from '../../_models/series';
import { SeriesMetadata } from '../../_models/series-metadata';
import { MetadataService } from '../../_services/metadata.service';
const MAX_WORDS_PER_HOUR = 30_000;
const MIN_WORDS_PER_HOUR = 10_260;
const MAX_PAGES_PER_MINUTE = 2.75;
const MIN_PAGES_PER_MINUTE = 3.33;
@Component({
selector: 'app-series-metadata-detail',
@ -34,6 +31,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
/**
* Html representation of Series Summary
@ -52,7 +50,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
return FilterQueryParam;
}
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private router: Router) { }
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private router: Router, public readerService: ReaderService) {
}
ngOnChanges(changes: SimpleChanges): void {
this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 ||
@ -67,17 +67,17 @@ 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);
if (this.series.format === MangaFormat.EPUB && this.series.wordCount > 0) {
this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10);
this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10);
this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10) || 1;
this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10) || 1;
} else if (this.series.format !== MangaFormat.EPUB) {
this.minHoursToRead = parseInt(Math.round((this.series.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10);
this.maxHoursToRead = parseInt(Math.round((this.series.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10);
this.minHoursToRead = parseInt(Math.round((this.series.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10) || 1;
this.maxHoursToRead = parseInt(Math.round((this.series.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10) || 1;
}
}
}

View File

@ -2,7 +2,7 @@
(click)="handleClick($event)">
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
<div style="padding-top: 5px">
<div class="text">
<ng-content></ng-content>
</div>
</div>

View File

@ -6,4 +6,10 @@
.icon {
width: 20px;
height: 20px;
text-align: center;
}
.text {
padding-top: 5px;
text-align: center;
}

View File

@ -36,6 +36,7 @@
@import './theme/components/progress';
@import './theme/components/sidenav';
@import './theme/components/carousel';
@import './theme/components/offcanvas';
@import './theme/utilities/utilities';

View File

@ -0,0 +1,12 @@
.offcanvas {
color: var(--drawer-text-color);
background-color: var(--drawer-bg-color);
}
.offcanvas-end, .offcanvas-start, .offcanvas-top {
top: 56px;
}
.offcanvas-bottom {
height: 40vh;
}