mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
d796bcdc0a
commit
63475722ea
@ -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));
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
|
||||
}
|
||||
}
|
||||
|
24
API/DTOs/Reader/HourEstimateRangeDto.cs
Normal file
24
API/DTOs/Reader/HourEstimateRangeDto.cs
Normal 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;
|
||||
}
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
25
UI/Web/package-lock.json
generated
25
UI/Web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -17,6 +17,8 @@ export interface ChapterMetadata {
|
||||
summary: string;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
wordCount: number;
|
||||
|
||||
|
||||
|
||||
genres: Array<Genre>;
|
||||
|
6
UI/Web/src/app/_models/hour-estimate-range.ts
Normal file
6
UI/Web/src/app/_models/hour-estimate-range.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface HourEstimateRange{
|
||||
minHours: number;
|
||||
maxHours: number;
|
||||
avgHours: number;
|
||||
hasProgress: boolean;
|
||||
}
|
@ -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'});
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
@ -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
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
||||
|
@ -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 { }
|
||||
|
@ -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]);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 '';
|
||||
}));
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -6,4 +6,10 @@
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding-top: 5px;
|
||||
text-align: center;
|
||||
}
|
@ -36,6 +36,7 @@
|
||||
@import './theme/components/progress';
|
||||
@import './theme/components/sidenav';
|
||||
@import './theme/components/carousel';
|
||||
@import './theme/components/offcanvas';
|
||||
|
||||
|
||||
@import './theme/utilities/utilities';
|
||||
|
12
UI/Web/src/theme/components/_offcanvas.scss
Normal file
12
UI/Web/src/theme/components/_offcanvas.scss
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user