mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Infinite Scroll + List View + Cover Upload Redesign (#1319)
* Started with the redesign of the cover image chooser redesign to be less click intensive for volume/chapter images. Made some headings bold in card detail drawer. * Tweaked the styles * Moved where the info cards show * Added an ability to open a page settings drawer * Cleaned up some old code that isn't needed anymore. * Started implementing a list view. Refactored some title code to a dedicated component * List view implemented but way too many API calls. Either need caching or adjusting the SeriesDetail api. * Fixed a bug where if the progress bar didn't render on a card item while a download was in progress, the download indicator would be removed. * Large refactor to move a lot of the needed fields to the chapter and volume dtos for series detail. All fields are noted when only used in series detail. * Implemented cards for other tabs (except related) * Fixed the unit test which needed a mocked reader service call. * More cleanup around age rating and removing old code from the refactor. Commented out sorting till i feel motivated to work on that. * Some cleanup and restored cards as initial layout. Time to test this out and see if there is value add. * Added ability for Chapters tab to show the volume chapters belong to (if applicable) * Adding style fixes * Cover image updates, don't allow the first image (which is what is currently set) to respond to cover changes. Hide the ID field on list item for series detail. * Refactored the title for list item to be injectable * Cleaned up the selection code to make it less finicky on mobile when tap scrolling. * Refactored chapter tab to show volume as well on list view. * Ensure word count shows for Volumes * Started adding virtual scrolling, pushing up so Robbie can mess around * Started adding virtual scrolling, pushing up so Robbie can mess around * Fixed a bug where all chapters would come under specials * Show title data as accent if set. * Style fixes for virtual scroller * Restyling scroll * Implemented a way to show storyline with virtual scrolling * Show Word Count for chapters and cleaned up some logics. * I might have card layout working with virtual scroll code. * Some cleanup to hide more system like properties from info bar on series detail page. Fixed some missing time estimate info on storyline chapters. * Fixed a regression on series service when I integrated VolumeTitle. * Refactored read time to the backend. Added WordCount to the volume itself so we don't need to calculate on frontend. When asking to analyze files from a series, force the calculation. * Fixed SeriesDetail api code * Fixed up the code in the drawer to better update list/card mode * Basic infinite scroll implemented, however due to how we are updating the list to render, we are re-rending cards that haven't been touched. * Updated how we render and layout data for infinite scroll on library detail. It's almost there. * Started laying foundation for loading pages backwards. Removed lazy loading of images since we are now using virtual paging. * Hooked in some basic code to allow user to load a prev page with infinite scroll. * Fixed up series detail api and undid the non-lazy loaded images. Changed the router to help with this infinite loading on Firefox issue. * Fixed up some naming issues with Series Detail and added a new test. * This is an infinite scroll without pagination implementation. It is not fully done, but off to a good start. Virtual scroller with jump bar is working pretty well, def needs more polishing and tweaking. There are hacks in this implementation that need to be revisited. * Refactored code so that we don't use any pagination and load all results by default. * Misc code cleanup from build warnings. * Cleaned up some logic for how to display titles in list view. * More title cleanup for specials * Hooked up page layout to user preferences and renamed an existing user pref name to match the dto. * Swapped out everything but storyline with virtual-scroller over CDK * Removed CDK from series detail. * Default value for migration on page layout * Updating card layout for library detail page * fixing height for mobile * Moved scrollbar * Tweaked some styling for layouts when there is no data * Refactored the series cards into their own component to make it re-usable. * More tweaks on series info cards layout and enhanced a few pages with trackby functions. * Removed some dead code * Added download on series detail to actionables to fit in with new scroll strategy. * Fixed language not being updated and sent to the backend for series update. * Fixed a bad migration (if you ran any prior migration in this branch, you need to undo before you use this commit) * Adding sticky tabs * fixed mobile gap on sticky tab * Enhanced the card title for books to show number up front. * Adjusted the gutters on admin dashboard * Removed debug code * Removing duplicate book title * Cleaned up old references to cdk scroller * Implemented a basic jump bar scaling algorithm. Not perfect, but works pretty well. * Code smells Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
f0f0e23e88
commit
bbc48a5f5b
@ -16,7 +16,7 @@ namespace API.Benchmark;
|
||||
public class EpubBenchmark
|
||||
{
|
||||
[Benchmark]
|
||||
public async Task GetWordCount_PassByString()
|
||||
public static async Task GetWordCount_PassByString()
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
|
||||
foreach (var bookFile in book.Content.Html.Values)
|
||||
@ -27,7 +27,7 @@ public class EpubBenchmark
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public async Task GetWordCount_PassByRef()
|
||||
public static async Task GetWordCount_PassByRef()
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
|
||||
foreach (var bookFile in book.Content.Html.Values)
|
||||
|
@ -8,6 +8,7 @@ using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
@ -21,6 +22,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.Extensions;
|
||||
using NSubstitute.ReceivedExtensions;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
@ -253,6 +256,50 @@ public class SeriesServiceTests
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnCorrectNaming_VolumeTitle()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
// volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
|
||||
Assert.Equal(3, detail.Chapters.Count());
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
|
||||
Assert.Equal(string.Empty, detail.Chapters.First().VolumeTitle); // loose leaf chapter
|
||||
Assert.Equal("Volume 3", detail.Chapters.Last().VolumeTitle); // volume based chapter
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
|
||||
{
|
||||
@ -700,7 +747,7 @@ public class SeriesServiceTests
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series.Metadata);
|
||||
Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "New Genre".SentenceCase()));
|
||||
Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase()));
|
||||
Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
|
@ -12,6 +12,7 @@ using API.DTOs.Account;
|
||||
using API.DTOs.Email;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
@ -652,22 +653,13 @@ namespace API.Controllers
|
||||
try
|
||||
{
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
//if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
|
||||
|
||||
user.Email = dto.Email;
|
||||
if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration");
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
//var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
|
||||
// _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink);
|
||||
// // Always send an email, even if the user can't click it just to get them conformable with the system
|
||||
// await _emailService.SendMigrationEmail(new EmailMigrationDto()
|
||||
// {
|
||||
// EmailAddress = dto.Email,
|
||||
// Username = user.UserName,
|
||||
// ServerConfirmationLink = emailLink
|
||||
// });
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -181,7 +181,7 @@ namespace API.Controllers
|
||||
[HttpPost("analyze")]
|
||||
public ActionResult Analyze(int libraryId)
|
||||
{
|
||||
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
|
||||
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
|
||||
@ -104,7 +104,7 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(_unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
|
||||
@ -125,7 +125,7 @@ public class MetadataController : BaseApiController
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
var englishTag = CultureInfo.GetCultureInfo("en");
|
||||
|
@ -604,6 +604,7 @@ public class OpdsController : BaseApiController
|
||||
/// <summary>
|
||||
/// Downloads a file
|
||||
/// </summary>
|
||||
/// <param name="apiKey">User's API Key</param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="chapterId"></param>
|
||||
|
@ -628,32 +628,6 @@ namespace API.Controllers
|
||||
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Given word count, page count, and if the entity is an epub file, this will return the read time.
|
||||
/// </summary>
|
||||
/// <param name="wordCount"></param>
|
||||
/// <param name="pageCount"></param>
|
||||
/// <param name="isEpub"></param>
|
||||
/// <returns>Will always assume no progress as it's not privy</returns>
|
||||
[HttpGet("manual-read-time")]
|
||||
public ActionResult<HourEstimateRangeDto> GetManualReadTime(int wordCount, int pageCount, bool isEpub)
|
||||
{
|
||||
return Ok(_readerService.GetTimeEstimate(wordCount, pageCount, isEpub));
|
||||
}
|
||||
|
||||
[HttpGet("read-time")]
|
||||
public async Task<ActionResult<HourEstimateRangeDto>> GetReadTime(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
|
||||
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
|
||||
return Ok(_readerService.GetTimeEstimate(series.WordCount, series.Pages, series.Format == MangaFormat.Epub,
|
||||
progress.Any()));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// For the current user, returns an estimate on how long it would take to finish reading the series.
|
||||
/// </summary>
|
||||
@ -675,12 +649,12 @@ namespace API.Controllers
|
||||
// Word count
|
||||
var progressCount = chapters.Sum(c => c.WordCount);
|
||||
var wordsLeft = series.WordCount - progressCount;
|
||||
return _readerService.GetTimeEstimate(wordsLeft, 0, true, progressCount > 0);
|
||||
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
|
||||
}
|
||||
|
||||
var progressPageCount = progress.Sum(p => p.PagesRead);
|
||||
var pagesLeft = series.Pages - progressPageCount;
|
||||
return _readerService.GetTimeEstimate(0, pagesLeft, false, progressPageCount > 0);
|
||||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ public class RecommendedController : BaseApiController
|
||||
/// 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>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("quick-reads")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
|
||||
@ -57,6 +58,7 @@ public class RecommendedController : BaseApiController
|
||||
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("highly-rated")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
|
||||
@ -74,6 +76,8 @@ public class RecommendedController : BaseApiController
|
||||
/// Chooses a random genre and shows series that are in that without reading progress
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="genreId">Genre Id</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("more-in")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
|
||||
@ -92,6 +96,7 @@ public class RecommendedController : BaseApiController
|
||||
/// Series that are fully read by the user in no particular order
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("rediscover")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
|
||||
|
@ -92,8 +92,9 @@ namespace API.Controllers
|
||||
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
|
||||
preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
|
||||
existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode;
|
||||
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
|
||||
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
|
||||
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
|
||||
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
|
||||
|
||||
// TODO: Remove this code - this overrides layout mode to be single until the mode is released
|
||||
|
@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
@ -9,7 +11,7 @@ namespace API.DTOs
|
||||
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
|
||||
/// file (abstracted from type).
|
||||
/// </summary>
|
||||
public class ChapterDto
|
||||
public class ChapterDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
/// <summary>
|
||||
@ -62,5 +64,30 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
/// <remarks>Metadata field</remarks>
|
||||
public string TitleName { get; set; }
|
||||
/// <summary>
|
||||
/// Summary of the Chapter
|
||||
/// </summary>
|
||||
/// <remarks>This is not set normally, only for Series Detail</remarks>
|
||||
public string Summary { get; init; }
|
||||
/// <summary>
|
||||
/// Age Rating for the issue/chapter
|
||||
/// </summary>
|
||||
public AgeRating AgeRating { get; init; }
|
||||
/// <summary>
|
||||
/// Total words in a Chapter (books only)
|
||||
/// </summary>
|
||||
public long WordCount { get; set; } = 0L;
|
||||
|
||||
/// <summary>
|
||||
/// Formatted Volume title ie) Volume 2.
|
||||
/// </summary>
|
||||
/// <remarks>Only available when fetched from Series Detail API</remarks>
|
||||
public string VolumeTitle { get; set; } = string.Empty;
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -3,22 +3,18 @@
|
||||
/// <summary>
|
||||
/// A range of time to read a selection (series, chapter, etc)
|
||||
/// </summary>
|
||||
public class HourEstimateRangeDto
|
||||
public record HourEstimateRangeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Min hours to read the selection
|
||||
/// </summary>
|
||||
public int MinHours { get; set; } = 1;
|
||||
public int MinHours { get; init; } = 1;
|
||||
/// <summary>
|
||||
/// Max hours to read the selection
|
||||
/// </summary>
|
||||
public int MaxHours { get; set; } = 1;
|
||||
public int MaxHours { get; init; } = 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;
|
||||
public int AvgHours { get; init; } = 1;
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class SeriesDto
|
||||
public class SeriesDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
@ -47,5 +48,11 @@ namespace API.DTOs
|
||||
|
||||
public int LibraryId { get; set; }
|
||||
public string LibraryName { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Url of the file to download from (can be null)
|
||||
/// Base Url encoding of the file to upload from (can be null)
|
||||
/// </summary>
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
@ -82,5 +83,10 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Cards</remarks>
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class VolumeDto
|
||||
public class VolumeDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Number { get; set; }
|
||||
@ -15,5 +16,11 @@ namespace API.DTOs
|
||||
public DateTime Created { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public ICollection<ChapterDto> Chapters { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -78,10 +79,14 @@ namespace API.Data
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.BackgroundColor)
|
||||
.HasDefaultValue("#000000");
|
||||
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.GlobalPageLayoutMode)
|
||||
.HasDefaultValue(PageLayoutMode.Cards);
|
||||
}
|
||||
|
||||
|
||||
static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
|
||||
private static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
|
||||
{
|
||||
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
|
||||
{
|
||||
@ -91,7 +96,7 @@ namespace API.Data
|
||||
|
||||
}
|
||||
|
||||
static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
|
||||
private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
|
||||
{
|
||||
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
|
||||
entity.LastModified = DateTime.Now;
|
||||
|
@ -19,6 +19,9 @@ public static class MigrateBookmarks
|
||||
/// </summary>
|
||||
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
|
||||
/// <param name="directoryService"></param>
|
||||
/// <param name="unitOfWork"></param>
|
||||
/// <param name="logger"></param>
|
||||
/// <param name="cacheService"></param>
|
||||
/// <returns></returns>
|
||||
public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork,
|
||||
ILogger<Program> logger, ICacheService cacheService)
|
||||
|
@ -148,7 +148,7 @@ namespace API.Data
|
||||
var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault();
|
||||
var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting);
|
||||
if (firstChapter == null) continue;
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png")))
|
||||
|
1562
API/Data/Migrations/20220610153822_TimeEstimateInDB.Designer.cs
generated
Normal file
1562
API/Data/Migrations/20220610153822_TimeEstimateInDB.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
125
API/Data/Migrations/20220610153822_TimeEstimateInDB.cs
Normal file
125
API/Data/Migrations/20220610153822_TimeEstimateInDB.cs
Normal file
@ -0,0 +1,125 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class TimeEstimateInDB : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "WordCount",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WordCount",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Chapter");
|
||||
}
|
||||
}
|
||||
}
|
1562
API/Data/Migrations/20220613131125_RenamedBookReaderLayoutMode.Designer.cs
generated
Normal file
1562
API/Data/Migrations/20220613131125_RenamedBookReaderLayoutMode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class RenamedBookReaderLayoutMode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "PageLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
newName: "BookReaderLayoutMode");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "BookReaderLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
newName: "PageLayoutMode");
|
||||
}
|
||||
}
|
||||
}
|
1567
API/Data/Migrations/20220613131302_GlobalPageLayoutModeUserSetting.Designer.cs
generated
Normal file
1567
API/Data/Migrations/20220613131302_GlobalPageLayoutModeUserSetting.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class GlobalPageLayoutModeUserSetting : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GlobalPageLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlobalPageLayoutMode",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
@ -179,6 +179,9 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("BookReaderImmersiveMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLineSpacing")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -196,10 +199,12 @@ namespace API.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("Dark");
|
||||
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<int>("GlobalPageLayoutMode")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("PageLayoutMode")
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
@ -320,6 +325,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -341,6 +349,12 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -732,6 +746,9 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -759,6 +776,12 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("LocalizedNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -868,6 +891,9 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -877,6 +903,12 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -889,6 +921,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
@ -1,14 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.JumpBar;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Kavita.Common.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
@ -41,6 +44,9 @@ public interface ILibraryRepository
|
||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
|
||||
Task<int> GetTotalFiles();
|
||||
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
@ -258,4 +264,54 @@ public class LibraryRepository : ILibraryRepository
|
||||
}
|
||||
|
||||
|
||||
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.AgeRating)
|
||||
.Distinct()
|
||||
.Select(s => new AgeRatingDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsNoTracking()
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.PublicationStatus)
|
||||
.Distinct()
|
||||
.AsEnumerable()
|
||||
.Select(s => new PublicationStatusDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.OrderBy(s => s.Title);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -68,7 +68,8 @@ public interface ISeriesRepository
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="userParams"></param>
|
||||
/// <param name="userParams">Pagination info</param>
|
||||
/// <param name="filter">Filtering/Sorting to apply</param>
|
||||
/// <returns></returns>
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
/// <summary>
|
||||
@ -107,9 +108,7 @@ public interface ISeriesRepository
|
||||
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
|
||||
Task<Chunk> GetChunkInfo(int libraryId = 0);
|
||||
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
|
||||
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
|
||||
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
|
||||
@ -922,54 +921,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.AgeRating)
|
||||
.Distinct()
|
||||
.Select(s => new AgeRatingDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsNoTracking()
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.PublicationStatus)
|
||||
.Distinct()
|
||||
.AsEnumerable()
|
||||
.Select(s => new PublicationStatusDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.OrderBy(s => s.Title);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
@ -978,6 +930,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
/// <remarks>This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping
|
||||
/// in memory, we stop after 30 series. </remarks>
|
||||
/// <param name="userId">Used to ensure user has access to libraries</param>
|
||||
/// <param name="pageSize">How many entities to return</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
|
||||
{
|
||||
@ -1234,7 +1187,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
|
||||
{
|
||||
var libraries = await _context.AppUser
|
||||
.Where(u => u.Id == userId)
|
||||
|
@ -1,4 +1,5 @@
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
@ -81,13 +82,17 @@ namespace API.Entities
|
||||
/// 2 column is fit to height, 2 columns
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Default</remarks>
|
||||
public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default;
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default;
|
||||
/// <summary>
|
||||
/// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this.
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Cards</remarks>
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
|
||||
public AppUser AppUser { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
|
@ -3,10 +3,11 @@ using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
public class Chapter : IEntityDate
|
||||
public class Chapter : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@ -24,7 +25,7 @@ namespace API.Entities
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
/// <summary>
|
||||
/// Absolute path to the (managed) image file
|
||||
/// Relative path to the (managed) image file representing the cover image
|
||||
/// </summary>
|
||||
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||
public string CoverImage { get; set; }
|
||||
@ -73,9 +74,16 @@ namespace API.Entities
|
||||
public int Count { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Total words in a Chapter (books only)
|
||||
/// Total Word count of all chapters in this chapter.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
@ -7,10 +7,6 @@
|
||||
/// </summary>
|
||||
Other = 1,
|
||||
/// <summary>
|
||||
/// Artist
|
||||
/// </summary>
|
||||
//Artist = 2,
|
||||
/// <summary>
|
||||
/// Author or Writer
|
||||
/// </summary>
|
||||
Writer = 3,
|
||||
|
11
API/Entities/Enums/UserPreferences/PageLayoutMode.cs
Normal file
11
API/Entities/Enums/UserPreferences/PageLayoutMode.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.UserPreferences;
|
||||
|
||||
public enum PageLayoutMode
|
||||
{
|
||||
[Description("Cards")]
|
||||
Cards = 0,
|
||||
[Description("List")]
|
||||
List = 1
|
||||
}
|
25
API/Entities/Interfaces/IHasReadTimeEstimate.cs
Normal file
25
API/Entities/Interfaces/IHasReadTimeEstimate.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using API.Services;
|
||||
|
||||
namespace API.Entities.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Entity has read time estimate properties to estimate time to read
|
||||
/// </summary>
|
||||
public interface IHasReadTimeEstimate
|
||||
{
|
||||
/// <summary>
|
||||
/// Min hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <summary>
|
||||
/// Max hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <summary>
|
||||
/// Average hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
@ -6,7 +6,7 @@ using API.Entities.Metadata;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
public class Series : IEntityDate
|
||||
public class Series : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@ -66,10 +66,15 @@ public class Series : IEntityDate
|
||||
public DateTime LastChapterAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total words in a Series (books only)
|
||||
/// Total Word count of all chapters in this chapter.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
|
||||
public int MinHoursToRead { get; set; }
|
||||
public int MaxHoursToRead { get; set; }
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
public SeriesMetadata Metadata { get; set; }
|
||||
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||
@ -87,5 +92,4 @@ public class Series : IEntityDate
|
||||
public List<Volume> Volumes { get; set; }
|
||||
public Library Library { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
|
||||
}
|
||||
|
@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
public class Volume : IEntityDate
|
||||
public class Volume : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@ -25,12 +24,23 @@ namespace API.Entities
|
||||
/// </summary>
|
||||
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||
public string CoverImage { get; set; }
|
||||
/// <summary>
|
||||
/// Total pages of all chapters in this volume
|
||||
/// </summary>
|
||||
public int Pages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total Word count of all chapters in this volume.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
public int MinHoursToRead { get; set; }
|
||||
public int MaxHoursToRead { get; set; }
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
|
||||
// Relationships
|
||||
public Series Series { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +113,7 @@ namespace API.Helpers
|
||||
opt.MapFrom(src => src.BookThemeName))
|
||||
.ForMember(dest => dest.BookReaderLayoutMode,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.PageLayoutMode));
|
||||
opt.MapFrom(src => src.BookReaderLayoutMode));
|
||||
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
@ -32,6 +32,7 @@ public class CacheHelper : ICacheHelper
|
||||
/// <remarks>If a cover image is locked but the underlying file has been deleted, this will allow regenerating. </remarks>
|
||||
/// <param name="coverPath">This should just be the filename, no path information</param>
|
||||
/// <param name="firstFile"></param>
|
||||
/// <param name="chapterCreated">When the chapter was created (Not Used)</param>
|
||||
/// <param name="forceUpdate">If the user has told us to force the refresh</param>
|
||||
/// <param name="isCoverLocked">If cover has been locked by user. This will force false</param>
|
||||
/// <returns></returns>
|
||||
|
@ -2,14 +2,17 @@
|
||||
{
|
||||
public class UserParams
|
||||
{
|
||||
private const int MaxPageSize = 50;
|
||||
public int PageNumber { get; set; } = 1;
|
||||
private int _pageSize = 30;
|
||||
private const int MaxPageSize = int.MaxValue;
|
||||
public int PageNumber { get; init; } = 1;
|
||||
private readonly int _pageSize = 30;
|
||||
|
||||
/// <summary>
|
||||
/// If set to 0, will set as MaxInt
|
||||
/// </summary>
|
||||
public int PageSize
|
||||
{
|
||||
get => _pageSize;
|
||||
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
|
||||
init => _pageSize = (value == 0) ? MaxPageSize : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -573,15 +573,13 @@ namespace API.Parser
|
||||
foreach (var regex in MangaEditionRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Edition"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Edition"].Success && match.Groups["Edition"].Value != string.Empty)
|
||||
{
|
||||
var edition = match.Groups["Edition"].Value.Replace("{", "").Replace("}", "")
|
||||
.Replace("[", "").Replace("]", "").Replace("(", "").Replace(")", "");
|
||||
|
||||
return edition;
|
||||
}
|
||||
return group.Value
|
||||
.Replace("{", "").Replace("}", "")
|
||||
.Replace("[", "").Replace("]", "")
|
||||
.Replace("(", "").Replace(")", "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -596,15 +594,8 @@ namespace API.Parser
|
||||
public static bool HasSpecialMarker(string filePath)
|
||||
{
|
||||
var matches = SpecialMarkerRegex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return matches.Select(match => match.Groups["Special"])
|
||||
.Any(group => group.Success && group != Match.Empty);
|
||||
}
|
||||
|
||||
public static string ParseMangaSpecial(string filePath)
|
||||
@ -612,12 +603,10 @@ namespace API.Parser
|
||||
foreach (var regex in MangaSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Special"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return match.Groups["Special"].Value;
|
||||
}
|
||||
return group.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -629,12 +618,10 @@ namespace API.Parser
|
||||
foreach (var regex in ComicSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Special"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return match.Groups["Special"].Value;
|
||||
}
|
||||
return group.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -646,12 +633,10 @@ namespace API.Parser
|
||||
foreach (var regex in MangaSeriesRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Series"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
|
||||
{
|
||||
return CleanTitle(match.Groups["Series"].Value);
|
||||
}
|
||||
return CleanTitle(group.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -662,12 +647,10 @@ namespace API.Parser
|
||||
foreach (var regex in ComicSeriesRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Series"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
|
||||
{
|
||||
return CleanTitle(match.Groups["Series"].Value, true);
|
||||
}
|
||||
return CleanTitle(group.Value, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -697,12 +680,12 @@ namespace API.Parser
|
||||
foreach (var regex in ComicVolumeRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups))
|
||||
{
|
||||
if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue;
|
||||
if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue;
|
||||
|
||||
var value = match.Groups["Volume"].Value;
|
||||
var hasPart = match.Groups["Part"].Success;
|
||||
var value = group["Volume"].Value;
|
||||
var hasPart = group["Part"].Success;
|
||||
return FormatValue(value, hasPart);
|
||||
}
|
||||
}
|
||||
@ -808,12 +791,9 @@ namespace API.Parser
|
||||
foreach (var regex in MangaSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@ -825,12 +805,9 @@ namespace API.Parser
|
||||
foreach (var regex in EuropeanComicRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@ -842,12 +819,9 @@ namespace API.Parser
|
||||
foreach (var regex in ComicSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@ -905,12 +879,9 @@ namespace API.Parser
|
||||
foreach (var regex in ReleaseGroupRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty);
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -249,7 +249,7 @@ namespace API.Services
|
||||
|
||||
/// <summary>
|
||||
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
|
||||
/// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
|
||||
/// under extract path and not nested in subfolders. See <see cref="DirectoryService"/> Flatten method.
|
||||
/// </summary>
|
||||
/// <param name="archive">An opened archive stream</param>
|
||||
/// <returns></returns>
|
||||
|
@ -32,7 +32,7 @@ namespace API.Services
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
||||
void CleanupBookmarkCache(int bookmarkDtoSeriesId);
|
||||
void CleanupBookmarkCache(int seriesId);
|
||||
}
|
||||
public class CacheService : ICacheService
|
||||
{
|
||||
|
@ -724,7 +724,7 @@ namespace API.Services
|
||||
FileSystem.Path.Join(directoryName, "test.txt"),
|
||||
string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
ClearAndDeleteDirectory(directoryName);
|
||||
return false;
|
||||
|
@ -50,7 +50,7 @@ public class ImageService : IImageService
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount)
|
||||
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
|
||||
{
|
||||
_directoryService.ExistOrCreate(targetDirectory);
|
||||
if (fileCount == 1)
|
||||
|
@ -35,7 +35,7 @@ public interface IMetadataService
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true);
|
||||
}
|
||||
|
||||
public class MetadataService : IMetadataService
|
||||
|
@ -29,7 +29,7 @@ public interface IReaderService
|
||||
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
|
||||
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
|
||||
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
|
||||
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false);
|
||||
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
|
||||
}
|
||||
|
||||
public class ReaderService : IReaderService
|
||||
@ -330,7 +330,7 @@ public class ReaderService : IReaderService
|
||||
{
|
||||
var chapterVolume = volumes.FirstOrDefault();
|
||||
if (chapterVolume?.Number != 0) return -1;
|
||||
var firstChapter = chapterVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
|
||||
var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
|
||||
if (firstChapter == null) return -1;
|
||||
return firstChapter.Id;
|
||||
}
|
||||
@ -372,17 +372,16 @@ public class ReaderService : IReaderService
|
||||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
|
||||
var lastChapter = volume.Chapters
|
||||
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var lastVolume = volumes.OrderBy(v => v.Number).LastOrDefault();
|
||||
var lastVolume = volumes.MaxBy(v => v.Number);
|
||||
if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
|
||||
{
|
||||
var lastChapter = lastVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
@ -406,7 +405,7 @@ public class ReaderService : IReaderService
|
||||
if (progress.Count == 0)
|
||||
{
|
||||
// I think i need a way to sort volumes last
|
||||
return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters
|
||||
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
|
||||
.OrderBy(c => float.Parse(c.Number)).First();
|
||||
}
|
||||
|
||||
@ -499,41 +498,38 @@ public class ReaderService : IReaderService
|
||||
}
|
||||
}
|
||||
|
||||
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false)
|
||||
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
|
||||
{
|
||||
if (isEpub)
|
||||
{
|
||||
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 1);
|
||||
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 1);
|
||||
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
|
||||
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
|
||||
if (maxHours < minHours)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHours,
|
||||
MaxHours = minHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
|
||||
HasProgress = hasProgress
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
||||
};
|
||||
}
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = minHours,
|
||||
MaxHours = maxHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
|
||||
HasProgress = hasProgress
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
||||
};
|
||||
}
|
||||
|
||||
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 1);
|
||||
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 1);
|
||||
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
|
||||
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
|
||||
if (maxHoursPages < minHoursPages)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHoursPages,
|
||||
MaxHours = minHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
|
||||
HasProgress = hasProgress
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
||||
};
|
||||
}
|
||||
|
||||
@ -541,8 +537,7 @@ public class ReaderService : IReaderService
|
||||
{
|
||||
MinHours = minHoursPages,
|
||||
MaxHours = maxHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
|
||||
HasProgress = hasProgress
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -458,7 +459,6 @@ public class SeriesService : ISeriesService
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
|
||||
.OrderBy(v => Parser.Parser.MinNumberFromRange(v.Name))
|
||||
.ToList();
|
||||
var chapters = volumes.SelectMany(v => v.Chapters).ToList();
|
||||
|
||||
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
|
||||
var processedVolumes = new List<VolumeDto>();
|
||||
@ -479,8 +479,15 @@ public class SeriesService : ISeriesService
|
||||
processedVolumes.ForEach(v => v.Name = $"Volume {v.Name}");
|
||||
}
|
||||
|
||||
|
||||
var specials = new List<ChapterDto>();
|
||||
var chapters = volumes.SelectMany(v => v.Chapters.Select(c =>
|
||||
{
|
||||
if (v.Number == 0) return c;
|
||||
c.VolumeTitle = v.Name;
|
||||
return c;
|
||||
})).ToList();
|
||||
|
||||
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
chapter.Title = FormatChapterTitle(chapter, libraryType);
|
||||
@ -490,7 +497,6 @@ public class SeriesService : ISeriesService
|
||||
specials.Add(chapter);
|
||||
}
|
||||
|
||||
|
||||
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
|
||||
IEnumerable<ChapterDto> retChapters;
|
||||
if (libraryType == LibraryType.Book)
|
||||
@ -503,18 +509,17 @@ public class SeriesService : ISeriesService
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
|
||||
}
|
||||
|
||||
|
||||
var storylineChapters = volumes
|
||||
.Where(v => v.Number == 0)
|
||||
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
|
||||
|
||||
return new SeriesDetailDto()
|
||||
{
|
||||
Specials = specials,
|
||||
Chapters = retChapters,
|
||||
Volumes = processedVolumes,
|
||||
StorylineChapters = volumes
|
||||
.Where(v => v.Number == 0)
|
||||
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())
|
||||
|
||||
StorylineChapters = storylineChapters
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -180,7 +180,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
|
||||
}
|
||||
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
|
||||
|
@ -45,11 +45,6 @@ public class BackupService : IBackupService
|
||||
_config = config;
|
||||
_eventHub = eventHub;
|
||||
|
||||
// var maxRollingFiles = config.GetMaxRollingFiles();
|
||||
// var loggingSection = config.GetLoggingFileName();
|
||||
// var files = GetLogFiles(maxRollingFiles, loggingSection);
|
||||
|
||||
|
||||
_backupFiles = new List<string>()
|
||||
{
|
||||
"appsettings.json",
|
||||
@ -59,11 +54,6 @@ public class BackupService : IBackupService
|
||||
"kavita.db-shm", // This wont always be there
|
||||
"kavita.db-wal" // This wont always be there
|
||||
};
|
||||
|
||||
// foreach (var file in files.Select(f => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name))
|
||||
// {
|
||||
// _backupFiles.Add(file);
|
||||
// }
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName)
|
||||
|
@ -32,14 +32,16 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ICacheHelper _cacheHelper;
|
||||
private readonly IReaderService _readerService;
|
||||
|
||||
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
||||
ICacheHelper cacheHelper)
|
||||
ICacheHelper cacheHelper, IReaderService readerService)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_cacheHelper = cacheHelper;
|
||||
_readerService = readerService;
|
||||
}
|
||||
|
||||
|
||||
@ -142,58 +144,78 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
|
||||
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
|
||||
{
|
||||
if (series.Format != MangaFormat.Epub) return;
|
||||
var isEpub = series.Format == MangaFormat.Epub;
|
||||
|
||||
long totalSum = 0;
|
||||
|
||||
foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters))
|
||||
foreach (var volume in series.Volumes)
|
||||
{
|
||||
// This compares if it's changed since a file scan only
|
||||
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false,
|
||||
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
|
||||
continue;
|
||||
|
||||
long sum = 0;
|
||||
var fileCounter = 1;
|
||||
foreach (var file in chapter.Files.Select(file => file.FilePath))
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var pageCounter = 1;
|
||||
try
|
||||
// This compares if it's changed since a file scan only
|
||||
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate,
|
||||
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
|
||||
continue;
|
||||
|
||||
if (series.Format == MangaFormat.Epub)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
|
||||
|
||||
var totalPages = book.Content.Html.Values;
|
||||
foreach (var bookPage in totalPages)
|
||||
long sum = 0;
|
||||
var fileCounter = 1;
|
||||
foreach (var file in chapter.Files.Select(file => file.FilePath))
|
||||
{
|
||||
var progress = Math.Max(0F,
|
||||
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
|
||||
var pageCounter = 1;
|
||||
try
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
|
||||
|
||||
var totalPages = book.Content.Html.Values;
|
||||
foreach (var bookPage in totalPages)
|
||||
{
|
||||
var progress = Math.Max(0F,
|
||||
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
|
||||
ProgressEventType.Updated, useFileName ? file : series.Name));
|
||||
sum += await GetWordCountFromHtml(bookPage);
|
||||
pageCounter++;
|
||||
}
|
||||
|
||||
fileCounter++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
|
||||
$"{series.Name} - {file}"));
|
||||
return;
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
|
||||
ProgressEventType.Updated, useFileName ? file : series.Name));
|
||||
sum += await GetWordCountFromHtml(bookPage);
|
||||
pageCounter++;
|
||||
}
|
||||
|
||||
fileCounter++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
|
||||
$"{series.Name} - {file}"));
|
||||
return;
|
||||
chapter.WordCount = sum;
|
||||
series.WordCount += sum;
|
||||
volume.WordCount += sum;
|
||||
}
|
||||
|
||||
var est = _readerService.GetTimeEstimate(chapter.WordCount, chapter.Pages, isEpub);
|
||||
chapter.MinHoursToRead = est.MinHours;
|
||||
chapter.MaxHoursToRead = est.MaxHours;
|
||||
chapter.AvgHoursToRead = est.AvgHours;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
}
|
||||
|
||||
chapter.WordCount = sum;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
totalSum += sum;
|
||||
var volumeEst = _readerService.GetTimeEstimate(volume.WordCount, volume.Pages, isEpub);
|
||||
volume.MinHoursToRead = volumeEst.MinHours;
|
||||
volume.MaxHoursToRead = volumeEst.MaxHours;
|
||||
volume.AvgHoursToRead = volumeEst.AvgHours;
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
|
||||
}
|
||||
|
||||
series.WordCount = totalSum;
|
||||
var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub);
|
||||
series.MinHoursToRead = seriesEstimate.MinHours;
|
||||
series.MaxHoursToRead = seriesEstimate.MaxHours;
|
||||
series.AvgHoursToRead = seriesEstimate.AvgHours;
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
||||
@ -207,8 +229,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
if (textNodes == null) return 0;
|
||||
|
||||
return textNodes
|
||||
.Select(node => node.InnerText)
|
||||
.Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => char.IsLetter(s[0])))
|
||||
.Select(words => words.Count())
|
||||
.Where(wordCount => wordCount > 0)
|
||||
|
@ -772,7 +772,6 @@ public class ScannerService : IScannerService
|
||||
case PersonRole.Translator:
|
||||
if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Other:
|
||||
default:
|
||||
series.Metadata.People.Remove(person);
|
||||
break;
|
||||
|
@ -2,5 +2,6 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Omake/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=rewinded/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
1859
UI/Web/package-lock.json
generated
1859
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@
|
||||
"@angular/platform-browser-dynamic": "~13.2.2",
|
||||
"@angular/router": "~13.2.2",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@iharbeck/ngx-virtual-scroller": "^13.0.4",
|
||||
"@microsoft/signalr": "^6.0.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { HourEstimateRange } from './hour-estimate-range';
|
||||
import { MangaFile } from './manga-file';
|
||||
import { AgeRating } from './metadata/age-rating';
|
||||
import { AgeRatingDto } from './metadata/age-rating-dto';
|
||||
|
||||
/**
|
||||
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
|
||||
@ -23,4 +26,19 @@ export interface Chapter {
|
||||
* Actual name of the Chapter if populated in underlying metadata
|
||||
*/
|
||||
titleName: string;
|
||||
/**
|
||||
* Summary for the chapter
|
||||
*/
|
||||
summary?: string;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
|
||||
ageRating: AgeRating;
|
||||
releaseDate: string;
|
||||
wordCount: number;
|
||||
/**
|
||||
* 'Volume number'. Only available for SeriesDetail
|
||||
*/
|
||||
volumeTitle?: string;
|
||||
}
|
||||
|
@ -2,5 +2,5 @@ export interface HourEstimateRange{
|
||||
minHours: number;
|
||||
maxHours: number;
|
||||
avgHours: number;
|
||||
hasProgress: boolean;
|
||||
//hasProgress: boolean;
|
||||
}
|
10
UI/Web/src/app/_models/page-layout-mode.ts
Normal file
10
UI/Web/src/app/_models/page-layout-mode.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export enum PageLayoutMode {
|
||||
/**
|
||||
* Use Cards for laying out data
|
||||
*/
|
||||
Cards = 0,
|
||||
/**
|
||||
* Use list style for laying out items
|
||||
*/
|
||||
List = 1
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
|
||||
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
|
||||
import { BookPageLayoutMode } from '../book-page-layout-mode';
|
||||
import { PageLayoutMode } from '../page-layout-mode';
|
||||
import { PageSplitOption } from './page-split-option';
|
||||
import { ReaderMode } from './reader-mode';
|
||||
import { ReadingDirection } from './reading-direction';
|
||||
@ -31,6 +32,7 @@ export interface Preferences {
|
||||
|
||||
// Global
|
||||
theme: SiteTheme;
|
||||
globalPageLayoutMode: PageLayoutMode;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||
@ -39,3 +41,4 @@ export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.
|
||||
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
|
||||
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}];
|
||||
export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}];
|
||||
export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}];
|
||||
|
@ -52,4 +52,7 @@ export interface Series {
|
||||
* Number of words in the series
|
||||
*/
|
||||
wordCount: number;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Chapter } from './chapter';
|
||||
import { HourEstimateRange } from './hour-estimate-range';
|
||||
|
||||
export interface Volume {
|
||||
id: number;
|
||||
@ -8,5 +9,12 @@ export interface Volume {
|
||||
lastModified: string;
|
||||
pages: number;
|
||||
pagesRead: number;
|
||||
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
|
||||
chapters: Array<Chapter>;
|
||||
/**
|
||||
* This is only available on the object when fetched for SeriesDetail
|
||||
*/
|
||||
timeEstimate?: HourEstimateRange;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
||||
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
||||
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
@ -527,5 +527,4 @@ export class ActionService implements OnDestroy {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -67,6 +67,10 @@ export enum EVENTS {
|
||||
* When bulk bookmarks are being converted
|
||||
*/
|
||||
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
|
||||
/**
|
||||
* When files are being scanned to calculate word count
|
||||
*/
|
||||
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
@ -155,6 +159,13 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.WordCountAnalyzerProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.WordCountAnalyzerProgress,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.LibraryModified, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.LibraryModified,
|
||||
|
@ -22,7 +22,7 @@ export class MetadataService {
|
||||
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
|
||||
private validLanguages: Array<Language> = [];
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getAgeRating(ageRating: AgeRating) {
|
||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||
|
@ -9,11 +9,6 @@ import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/progress-bookmark';
|
||||
|
||||
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'
|
||||
})
|
||||
@ -129,18 +124,11 @@ export class ReaderService {
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
// TODO: Cache this information
|
||||
getTimeLeft(seriesId: number) {
|
||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getTimeToRead(seriesId: number) {
|
||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/read-time?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getManualTimeToRead(wordCount: number, pageCount: number, isEpub: boolean) {
|
||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/manual-read-time?wordCount=' + wordCount + '&pageCount=' + pageCount + '&isEpub=' + isEpub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
|
||||
*/
|
||||
|
@ -3,7 +3,7 @@
|
||||
Admin Dashboard
|
||||
</h2>
|
||||
</app-side-nav-companion-bar>
|
||||
<div class="container-fluid">
|
||||
<div class="container-fluid g-0">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
|
@ -8,6 +8,7 @@
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
|
@ -131,5 +131,5 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`;
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
}
|
||||
|
@ -71,8 +71,9 @@ const routes: Routes = [
|
||||
]
|
||||
},
|
||||
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
|
||||
//{path: '', pathMatch: 'full', redirectTo: 'login'}, // This shouldn't be needed
|
||||
{path: '**', pathMatch: 'full', redirectTo: 'libraries'},
|
||||
{path: '', pathMatch: 'full', redirectTo: 'login'},
|
||||
{path: '**', pathMatch: 'prefix', redirectTo: 'libraries'},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@ -33,6 +33,7 @@ export class AppComponent implements OnInit {
|
||||
this.ngbModal.dismissAll();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
|
@ -122,7 +122,7 @@
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col2">2 Column</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
@ -7,7 +7,9 @@
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingBookmarks"
|
||||
[items]="series">
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" (reload)="loadBookmarks()" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||
[supressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||
|
@ -28,6 +28,8 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
||||
clearingSeries: {[id: number]: boolean} = {};
|
||||
actions: ActionItem<Series>[] = [];
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
|
||||
private onDestroy: Subject<void> = new Subject<void>();
|
||||
|
||||
constructor(private readerService: ReaderService, private seriesService: SeriesService,
|
||||
|
@ -338,11 +338,13 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
if (this.metadata.language) {
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
if (this.metadata.language === undefined || this.metadata.language === null || this.metadata.language === '') {
|
||||
this.metadata.language = 'en';
|
||||
}
|
||||
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
@ -428,6 +430,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
model.nameLocked = this.series.nameLocked;
|
||||
model.sortNameLocked = this.series.sortNameLocked;
|
||||
model.localizedNameLocked = this.series.localizedNameLocked;
|
||||
model.language = this.metadata.language;
|
||||
apis.push(this.seriesService.updateSeries(model));
|
||||
}
|
||||
|
||||
@ -459,8 +462,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
this.metadata.genres = genres;
|
||||
}
|
||||
|
||||
updateLanguage(language: Language) {
|
||||
this.metadata.language = language.isoCode;
|
||||
updateLanguage(language: Array<Language>) {
|
||||
if (language.length === 0) {
|
||||
this.metadata.language = '';
|
||||
return;
|
||||
}
|
||||
this.metadata.language = language[0].isoCode;
|
||||
}
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
|
@ -1,27 +0,0 @@
|
||||
<div class="card" *ngIf="bookmark != undefined">
|
||||
<app-image height="230px" width="170px" [imageUrl]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"></app-image>
|
||||
|
||||
<div class="card-body" *ngIf="bookmark.page >= 0">
|
||||
<div class="header-row">
|
||||
<span class="card-title" tabindex="0">
|
||||
Page {{bookmark.page + 1}}
|
||||
</span>
|
||||
<span class="card-actions float-end" *ngIf="series != undefined">
|
||||
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
|
||||
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
|
||||
<ng-container *ngIf="isClearing; else notClearing">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</ng-container>
|
||||
<ng-template #notClearing>
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
|
||||
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,25 +0,0 @@
|
||||
.card-body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-overflow {
|
||||
font-size: 13px;
|
||||
width: 130px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { PageBookmark } from '../../_models/page-bookmark';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmark',
|
||||
templateUrl: './bookmark.component.html',
|
||||
styleUrls: ['./bookmark.component.scss']
|
||||
})
|
||||
export class BookmarkComponent implements OnInit {
|
||||
|
||||
@Input() bookmark: PageBookmark | undefined;
|
||||
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
|
||||
series: Series | undefined;
|
||||
|
||||
isClearing: boolean = false;
|
||||
isDownloading: boolean = false;
|
||||
|
||||
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.bookmark) {
|
||||
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
|
||||
this.series = series;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleClick(event: any) {
|
||||
|
||||
}
|
||||
|
||||
removeBookmark() {
|
||||
if (this.bookmark === undefined) return;
|
||||
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
|
||||
this.bookmarkRemoved.emit(this.bookmark);
|
||||
this.bookmark = undefined;
|
||||
});
|
||||
}
|
||||
}
|
@ -21,7 +21,7 @@ export class BulkOperationsComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
const navBar = document.querySelector('.navbar');
|
||||
if (navBar) {
|
||||
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height);
|
||||
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height); // TODO: We can make this fixed 63px
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,32 +1,8 @@
|
||||
<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>
|
||||
<span class="modal-title" id="modal-basic-title">
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="data" [seriesName]="parentName"></app-entity-title>
|
||||
</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
|
||||
</div>
|
||||
@ -44,7 +20,7 @@
|
||||
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-lg-11">
|
||||
<ng-container *ngIf="summary$ | async as summary; else noSummary">
|
||||
<ng-container *ngIf="summary.length > 0; else noSummary">
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</ng-container>
|
||||
<ng-template #noSummary>
|
||||
@ -53,73 +29,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-3">
|
||||
<ng-container *ngIf="totalPages > 0">
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
|
||||
{{totalPages | number:''}} Pages
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
<app-entity-info-cards [entity]="data"></app-entity-info-cards>
|
||||
|
||||
<ng-container *ngIf="chapterMetadata !== undefined && chapterMetadata.releaseDate && (chapterMetadata.releaseDate | date: 'shortDate') !== '1/1/01'">
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Release Date" [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 label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
|
||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 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 label="Word Count" [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 label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
|
||||
{{ageRating}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="chapter.created && chapter.created !== '' && (chapter.created | date: 'shortDate') !== '1/1/01'">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
|
||||
{{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 label="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
|
||||
{{data.id}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 2 rows to show some tags-->
|
||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
||||
@ -187,18 +98,13 @@
|
||||
<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>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls"
|
||||
[showReset]="chapter.coverImageLocked"
|
||||
[showApplyButton]="true"
|
||||
(applyCover)="applyCoverImage($event)"
|
||||
(resetCover)="resetCoverImage()"
|
||||
>
|
||||
</app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
|
@ -14,3 +14,7 @@
|
||||
overflow: auto;
|
||||
height: calc(40vh - 63px); // drawer height - offcanvas heading height
|
||||
}
|
||||
|
||||
.h6 {
|
||||
font-weight: 600;
|
||||
}
|
@ -2,7 +2,7 @@ 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 { finalize, Observable, of, take, takeWhile, tap } from 'rxjs';
|
||||
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
@ -50,21 +50,6 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
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> = [];
|
||||
|
||||
|
||||
@ -77,16 +62,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
active = this.tabs[0];
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
ageRating!: string;
|
||||
|
||||
summary$: Observable<string> = of('');
|
||||
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
|
||||
minHoursToRead: number = 1;
|
||||
maxHoursToRead: number = 1;
|
||||
/**
|
||||
* We use a separate variable because if this is a volume, we need a sum of all chapters
|
||||
*/
|
||||
totalPages: number = 0;
|
||||
summary: string = '';
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
@ -129,25 +105,14 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
|
||||
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
this.chapterMetadata = metadata;
|
||||
|
||||
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
|
||||
|
||||
this.totalPages = this.chapter.pages;
|
||||
if (!this.isChapter) {
|
||||
// Need to account for multiple chapters if this is a volume
|
||||
this.totalPages = this.utilityService.asVolume(this.data).chapters.map(c => c.pages).reduce((sum, d) => sum + d);
|
||||
}
|
||||
|
||||
this.readerService.getManualTimeToRead(this.chapterMetadata.wordCount, this.totalPages, this.chapter.files[0].format === MangaFormat.EPUB).subscribe((time) => this.readingTime = time);
|
||||
});
|
||||
|
||||
|
||||
if (this.isChapter) {
|
||||
this.summary$ = this.metadataService.getChapterSummary(this.data.id);
|
||||
this.summary = this.utilityService.asChapter(this.data).summary || '';
|
||||
} else {
|
||||
this.summary$ = this.metadataService.getChapterSummary(this.utilityService.asVolume(this.data).chapters[0].id);
|
||||
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
|
||||
}
|
||||
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
@ -180,7 +145,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
}
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close({coverImageUpdate: this.coverImageUpdate});
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
|
||||
formatChapterNumber(chapter: Chapter) {
|
||||
@ -196,36 +161,14 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
applyCoverImage(coverUrl: string) {
|
||||
this.uploadService.updateChapterCoverImage(this.chapter.id, coverUrl).subscribe(() => {});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
resetCoverImage() {
|
||||
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
|
||||
this.toastr.info('A job has been enqueued to regenerate the cover image');
|
||||
});
|
||||
}
|
||||
|
||||
markChapterAsRead(chapter: Chapter) {
|
||||
@ -292,13 +235,10 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
console.log('want to download: ', wantToDownload);
|
||||
if (!wantToDownload) { return; }
|
||||
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
tap(val => {
|
||||
console.log(val);
|
||||
}),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
|
@ -13,29 +13,39 @@
|
||||
</div>
|
||||
</div>
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
<div class="viewport-container">
|
||||
<div class="viewport-container" #scrollingBlock>
|
||||
<div class="content-container">
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
<div class="card-container mt-2 mb-2">
|
||||
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
|
||||
<virtual-scroller #scroll [items]="items" (vsEnd)="fetchMore($event)" [bufferAmount]="1">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
|
||||
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="pagination && items.length > 0 && pagination.totalPages > 1" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
|
||||
</div>
|
||||
<ng-template #cardTemplate>
|
||||
|
||||
<div class="grid row g-0" >
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
|
||||
<div class="mx-auto" *ngIf="items.length === 0 && !isLoading" style="width: 200px;">
|
||||
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
|
||||
</div>
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
@ -93,8 +103,8 @@
|
||||
|
||||
<ng-template #jumpBar>
|
||||
<div class="jump-bar">
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeys; let i = index;">
|
||||
<button class="btn btn-link {{i % 2 !== 0 ? 'd-lg-flex' : 'd-md-flex'}}" (click)="scrollTo(jumpKey)">
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
@ -17,7 +17,7 @@
|
||||
.card-container {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
//overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@ -73,3 +73,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller, virtual-scroller {
|
||||
width: 100%;
|
||||
//height: calc(100vh - 160px); // 64 is a random number, 523 for me.
|
||||
height: calc(var(--vh) * 100 - 160px);
|
||||
//height: calc(100vh - 160px);
|
||||
//background-color: red;
|
||||
//max-height: calc(var(--vh)*100 - 170px);
|
||||
}
|
||||
|
@ -1,28 +1,33 @@
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { AfterViewInit, Component, ContentChild, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
|
||||
import { from, Subject } from 'rxjs';
|
||||
import { AfterViewInit, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
||||
import { IPageInfo, VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { filter, from, map, pairwise, Subject, tap, throttleTime } from 'rxjs';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
|
||||
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
const SCROLL_BREAKPOINT = 300;
|
||||
const keySize = 24;
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss']
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
||||
|
||||
@Input() header: string = '';
|
||||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
// ?! we need to have chunks to render in, because if we scroll down, then up, then down, we don't want to trigger a duplicate call
|
||||
@Input() paginatedItems: PaginatedResult<any> | undefined;
|
||||
@Input() pagination!: Pagination;
|
||||
|
||||
// Filter Code
|
||||
@ -35,56 +40,119 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() trackByIdentity!: (index: number, item: any) => string;
|
||||
@Input() trackByIdentity!: TrackByFunction<any>; //(index: number, item: any) => string
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
|
||||
|
||||
@Input() jumpBarKeys: Array<JumpKey> = []; // This is aprox 784 pixels wide
|
||||
jumpBarKeysToRender: Array<JumpKey> = []; // Original
|
||||
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() pageChangeWithDirection: EventEmitter<0 | 1> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
|
||||
@ViewChild('.jump-bar') jumpBar!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('scroller') scroller!: CdkVirtualScrollViewport;
|
||||
|
||||
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
|
||||
|
||||
itemSize: number = 100; // Idk what this actually does. Less results in more items rendering, 5 works well with pagination. 230 is technically what a card is height wise
|
||||
|
||||
filter!: SeriesFilter;
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
updateApplied: number = 0;
|
||||
|
||||
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
|
||||
get Breakpoint() {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
constructor(private seriesService: SeriesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService) {
|
||||
constructor(private seriesService: SeriesService, public utilityService: UtilityService,
|
||||
@Inject(DOCUMENT) private document: Document, private ngZone: NgZone) {
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@HostListener('window:orientationchange', ['$event'])
|
||||
resizeJumpBar() {
|
||||
console.log('resizing jump bar');
|
||||
//const breakpoint = this.utilityService.getActiveBreakpoint();
|
||||
// if (window.innerWidth < 784) {
|
||||
// // We need to remove a few sections of keys
|
||||
// const len = this.jumpBarKeys.length;
|
||||
// if (this.jumpBarKeys.length <= 8) return;
|
||||
// this.jumpBarKeys = this.jumpBarKeys.filter((item, index) => {
|
||||
// return index % 2 === 0;
|
||||
// });
|
||||
// }
|
||||
// TODO: Debounce this
|
||||
|
||||
const fullSize = (this.jumpBarKeys.length * keySize) - 20;
|
||||
const currentSize = (this.document.querySelector('.jump-bar')?.getBoundingClientRect().height || fullSize + 20) - 20;
|
||||
if (currentSize >= fullSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNumberOfKeys = parseInt(Math.round(currentSize / keySize) + '', 10);
|
||||
const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3;
|
||||
if (removeCount <= 0) return;
|
||||
|
||||
|
||||
this.jumpBarKeysToRender = [];
|
||||
|
||||
|
||||
const midPoint = this.jumpBarKeys.length / 2;
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[0]);
|
||||
this.removeFirstPartOfJumpBar(midPoint, removeCount / 2);
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]);
|
||||
this.removeSecondPartOfJumpBar(midPoint, removeCount / 2);
|
||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]);
|
||||
|
||||
//console.log('End product: ', this.jumpBarKeysToRender);
|
||||
// console.log('End key size: ', this.jumpBarKeysToRender.length);
|
||||
}
|
||||
|
||||
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
|
||||
const removedIndexes: Array<number> = [];
|
||||
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
||||
let min = 100000000;
|
||||
let minIndex = -1;
|
||||
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
|
||||
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
||||
min = this.jumpBarKeys[i].size;
|
||||
minIndex = i;
|
||||
}
|
||||
}
|
||||
removedIndexes.push(minIndex);
|
||||
}
|
||||
// console.log('second: removing ', removedIndexes);
|
||||
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
|
||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
|
||||
const removedIndexes: Array<number> = [];
|
||||
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
||||
let min = 100000000;
|
||||
let minIndex = -1;
|
||||
for(let i = 1; i < midPoint; i++) {
|
||||
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
||||
min = this.jumpBarKeys[i].size;
|
||||
minIndex = i;
|
||||
}
|
||||
}
|
||||
removedIndexes.push(minIndex);
|
||||
}
|
||||
|
||||
// console.log('first: removing ', removedIndexes);
|
||||
for(let i = 1; i < midPoint; i++) {
|
||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}_${item?.libraryId}`;
|
||||
if (this.trackByIdentity === undefined) {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; // ${this.pagination?.currentPage}_
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (this.filterSettings === undefined) {
|
||||
@ -96,27 +164,49 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.resizeJumpBar();
|
||||
// this.scroller.elementScrolled().pipe(
|
||||
// map(() => this.scroller.measureScrollOffset('bottom')),
|
||||
// pairwise(),
|
||||
// filter(([y1, y2]) => ((y2 < y1 && y2 < SCROLL_BREAKPOINT))), // 140
|
||||
// throttleTime(200)
|
||||
// ).subscribe(([y1, y2]) => {
|
||||
// const movingForward = y2 < y1;
|
||||
// if (this.pagination.currentPage === this.pagination.totalPages || this.pagination.currentPage === 1 && !movingForward) return;
|
||||
// this.ngZone.run(() => {
|
||||
// console.log('Load next pages');
|
||||
|
||||
const parent = this.document.querySelector('.card-container');
|
||||
if (parent == null) return;
|
||||
console.log('card divs', this.document.querySelectorAll('div[id^="jumpbar-index--"]'));
|
||||
console.log('cards: ', this.document.querySelectorAll('.card'));
|
||||
// this.pagination.currentPage = this.pagination.currentPage + 1;
|
||||
// this.pageChangeWithDirection.emit(1);
|
||||
// });
|
||||
// });
|
||||
|
||||
Array.from(this.document.querySelectorAll('div')).forEach(elem => this.intersectionObserver.observe(elem));
|
||||
// this.scroller.elementScrolled().pipe(
|
||||
// map(() => this.scroller.measureScrollOffset('top')),
|
||||
// pairwise(),
|
||||
// filter(([y1, y2]) => y2 >= y1 && y2 < SCROLL_BREAKPOINT),
|
||||
// throttleTime(200)
|
||||
// ).subscribe(([y1, y2]) => {
|
||||
// if (this.pagination.currentPage === 1) return;
|
||||
// this.ngZone.run(() => {
|
||||
// console.log('Load prev pages');
|
||||
|
||||
// this.pagination.currentPage = this.pagination.currentPage - 1;
|
||||
// this.pageChangeWithDirection.emit(0);
|
||||
// });
|
||||
// });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.intersectionObserver.disconnect();
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
console.log('interception: ', entries.filter(e => e.target.hasAttribute('no-observe')));
|
||||
|
||||
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
@ -142,18 +232,21 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
this.updateApplied++;
|
||||
}
|
||||
|
||||
// onScroll() {
|
||||
loading: boolean = false;
|
||||
fetchMore(event: IPageInfo) {
|
||||
if (event.endIndex !== this.items.length - 1) return;
|
||||
if (event.startIndex < 0) return;
|
||||
console.log('Requesting next page ', (this.pagination.currentPage + 1), 'of data', event);
|
||||
this.loading = true;
|
||||
|
||||
// }
|
||||
// this.pagination.currentPage = this.pagination.currentPage + 1;
|
||||
// this.pageChangeWithDirection.emit(1);
|
||||
|
||||
// onScrollDown() {
|
||||
// console.log('scrolled down');
|
||||
// }
|
||||
// onScrollUp() {
|
||||
// console.log('scrolled up');
|
||||
// }
|
||||
|
||||
|
||||
// this.fetchNextChunk(this.items.length, 10).then(chunk => {
|
||||
// this.items = this.items.concat(chunk);
|
||||
// this.loading = false;
|
||||
// }, () => this.loading = false);
|
||||
}
|
||||
|
||||
scrollTo(jumpKey: JumpKey) {
|
||||
// TODO: Figure out how to do this
|
||||
@ -165,6 +258,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
}
|
||||
//console.log('scrolling to card that starts with ', jumpKey.key, + ' with index of ', targetIndex);
|
||||
|
||||
// Infinite scroll
|
||||
this.virtualScroller.scrollToIndex(targetIndex, true, undefined, 1000);
|
||||
return;
|
||||
|
||||
// Basic implementation based on itemsPerPage being the same.
|
||||
//var minIndex = this.pagination.currentPage * this.pagination.itemsPerPage;
|
||||
var targetPage = Math.max(Math.ceil(targetIndex / this.pagination.itemsPerPage), 1);
|
||||
@ -173,14 +270,18 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
|
||||
// Scroll to the element
|
||||
const elem = this.document.querySelector(`div[id="jumpbar-index--${targetIndex}"`);
|
||||
if (elem !== null) {
|
||||
elem.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
this.virtualScroller.scrollToIndex(targetIndex);
|
||||
// elem.scrollIntoView({
|
||||
// behavior: 'smooth'
|
||||
// });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// With infinite scroll, we can't just jump to a random place, because then our list of items would be out of sync.
|
||||
this.selectPageStr(targetPage + '');
|
||||
//this.pageChangeWithDirection.emit(1);
|
||||
|
||||
// if (minIndex > targetIndex) {
|
||||
// // We need to scroll forward (potentially to another page)
|
||||
|
@ -7,8 +7,8 @@
|
||||
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
|
||||
</ng-container>
|
||||
|
||||
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== total">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
<div class="progress-banner">
|
||||
<p *ngIf="read < total && total > 0 && read !== total"><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
|
@ -19,6 +19,7 @@ import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
|
||||
@Component({
|
||||
@ -129,7 +130,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
constructor(public imageService: ImageService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService,
|
||||
private messageHub: MessageHubService, private accountService: AccountService) {}
|
||||
private messageHub: MessageHubService, private accountService: AccountService, private scrollService: ScrollService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
@ -182,28 +183,18 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
const verticalOffset = (window.pageYOffset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
|
||||
this.prevTouchTime = event.timeStamp;
|
||||
this.prevOffset = verticalOffset;
|
||||
this.prevOffset = this.scrollService.scrollPosition;
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
const delta = event.timeStamp - this.prevTouchTime;
|
||||
const verticalOffset = (window.pageYOffset
|
||||
|| document.documentElement.scrollTop
|
||||
|| document.body.scrollTop || 0);
|
||||
const verticalOffset = this.scrollService.scrollPosition;
|
||||
|
||||
if (verticalOffset != this.prevOffset) {
|
||||
this.prevTouchTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (delta >= 300 && delta <= 1000) {
|
||||
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset)) {
|
||||
this.handleSelection();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
@ -23,6 +23,12 @@ 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';
|
||||
import { EntityTitleComponent } from './entity-title/entity-title.component';
|
||||
import { EntityInfoCardsComponent } from './entity-info-cards/entity-info-cards.component';
|
||||
import { ListItemComponent } from './list-item/list-item.component';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component';
|
||||
|
||||
|
||||
|
||||
|
||||
@ -43,6 +49,10 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
|
||||
FileInfoComponent,
|
||||
EditSeriesRelationComponent,
|
||||
CardDetailDrawerComponent,
|
||||
EntityTitleComponent,
|
||||
EntityInfoCardsComponent,
|
||||
ListItemComponent,
|
||||
SeriesInfoCardsComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -60,8 +70,7 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
|
||||
NgbCollapseModule,
|
||||
NgbRatingModule,
|
||||
|
||||
//ScrollingModule,
|
||||
//InfiniteScrollModule,
|
||||
VirtualScrollerModule,
|
||||
|
||||
|
||||
NgbOffcanvasModule, // Series Detail, action of cards
|
||||
@ -93,7 +102,16 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
|
||||
ChapterMetadataDetailComponent,
|
||||
EditSeriesRelationComponent,
|
||||
|
||||
NgbOffcanvasModule
|
||||
EntityTitleComponent,
|
||||
EntityInfoCardsComponent,
|
||||
ListItemComponent,
|
||||
|
||||
NgbOffcanvasModule,
|
||||
|
||||
VirtualScrollerModule,
|
||||
SeriesInfoCardsComponent
|
||||
|
||||
|
||||
]
|
||||
})
|
||||
export class CardsModule { }
|
||||
|
@ -48,11 +48,27 @@
|
||||
</form>
|
||||
|
||||
<div class="row g-0 chooser" style="padding-top: 10px">
|
||||
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
|
||||
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
|
||||
<div class="image-card col-auto"
|
||||
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
|
||||
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url" [processEvents]="idx > 0"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button class="btn btn-primary" style="width: 100%;" aria-label="Apply for uploaded image"
|
||||
(click)="applyImage(idx)">
|
||||
{{appliedIndex === idx ? 'Applied' : 'Apply'}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
|
||||
<div class="image-card col-auto"
|
||||
*ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()"
|
||||
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
|
||||
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
|
||||
<ng-container *ngIf="showApplyButton">
|
||||
<br>
|
||||
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -9,6 +9,8 @@ import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
||||
export type SelectCoverFunction = (selectedCover: string) => void;
|
||||
|
||||
@Component({
|
||||
selector: 'app-cover-image-chooser',
|
||||
templateUrl: './cover-image-chooser.component.html',
|
||||
@ -16,6 +18,19 @@ import { DOCUMENT } from '@angular/common';
|
||||
})
|
||||
export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* If buttons show under images to allow immediate selection of cover images.
|
||||
*/
|
||||
@Input() showApplyButton: boolean = false;
|
||||
/**
|
||||
* When a cover image is selected, this will be called with a base url representation of the file.
|
||||
*/
|
||||
@Output() applyCover: EventEmitter<string> = new EventEmitter<string>();
|
||||
/**
|
||||
* When a cover image is reset, this will be called.
|
||||
*/
|
||||
@Output() resetCover: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
@Input() imageUrls: Array<string> = [];
|
||||
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
|
||||
|
||||
@ -37,6 +52,10 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
|
||||
|
||||
selectedIndex: number = 0;
|
||||
/**
|
||||
* Only applies for showApplyButton. Used to track which image is applied.
|
||||
*/
|
||||
appliedIndex: number = 0;
|
||||
form!: FormGroup;
|
||||
files: NgxFileDropEntry[] = [];
|
||||
|
||||
@ -78,6 +97,19 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
|
||||
}
|
||||
|
||||
applyImage(index: number) {
|
||||
if (this.showApplyButton) {
|
||||
this.applyCover.emit(this.imageUrls[index]);
|
||||
this.appliedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
resetImage() {
|
||||
if (this.showApplyButton) {
|
||||
this.resetCover.emit();
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
const url = this.form.get('coverImageUrl')?.value.trim();
|
||||
if (url && url != '') {
|
||||
|
@ -0,0 +1,66 @@
|
||||
<div class="row g-0 mt-4 mb-3">
|
||||
<ng-container *ngIf="totalPages > 0">
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
|
||||
{{totalPages | number:''}} Pages
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
|
||||
{{chapter.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 && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
|
||||
<ng-container *ngIf="readingTime.maxHours === 0; else normalReadTime"><1 Hour</ng-container>
|
||||
<ng-template #normalReadTime>
|
||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
||||
</ng-template>
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
|
||||
{{totalWordCount | compactNumber}} Words
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="chapter.ageRating !== AgeRating.Unknown">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
|
||||
{{chapter.ageRating | ageRating | async}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="showExtendedProperties && chapter.created && chapter.created !== '' && (chapter.created | date: 'shortDate') !== '1/1/01'">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
|
||||
{{chapter.created | date:'short' || '-'}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="showExtendedProperties">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title label="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
|
||||
{{entity.id}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
@ -0,0 +1,96 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
|
||||
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-info-cards',
|
||||
templateUrl: './entity-info-cards.component.html',
|
||||
styleUrls: ['./entity-info-cards.component.scss']
|
||||
})
|
||||
export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() entity!: Volume | Chapter;
|
||||
/**
|
||||
* This will pull extra information
|
||||
*/
|
||||
@Input() includeMetadata: boolean = false;
|
||||
|
||||
/**
|
||||
* Hide more system based fields, like Id or Date Added
|
||||
*/
|
||||
@Input() showExtendedProperties: boolean = true;
|
||||
|
||||
isChapter = false;
|
||||
chapter!: Chapter;
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
ageRating!: string;
|
||||
totalPages: number = 0;
|
||||
totalWordCount: number = 0;
|
||||
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
||||
|
||||
private readonly onDestroy: Subject<void> = new Subject();
|
||||
|
||||
get LibraryType() {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
get MangaFormat() {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get AgeRating() {
|
||||
return AgeRating;
|
||||
}
|
||||
|
||||
constructor(private utilityService: UtilityService, private seriesService: SeriesService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.entity);
|
||||
|
||||
this.chapter = this.utilityService.isChapter(this.entity) ? (this.entity as Chapter) : (this.entity as Volume).chapters[0];
|
||||
|
||||
if (this.includeMetadata) {
|
||||
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
this.chapterMetadata = metadata;
|
||||
});
|
||||
}
|
||||
|
||||
this.totalPages = this.chapter.pages;
|
||||
if (!this.isChapter) {
|
||||
this.totalPages = this.utilityService.asVolume(this.entity).pages;
|
||||
}
|
||||
|
||||
this.totalWordCount = this.chapter.wordCount;
|
||||
if (!this.isChapter) {
|
||||
this.totalWordCount = this.utilityService.asVolume(this.entity).chapters.map(c => c.wordCount).reduce((sum, d) => sum + d);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (this.isChapter) {
|
||||
this.readingTime.minHours = this.chapter.minHoursToRead;
|
||||
this.readingTime.maxHours = this.chapter.maxHoursToRead;
|
||||
this.readingTime.avgHours = this.chapter.avgHoursToRead;
|
||||
} else {
|
||||
const vol = this.utilityService.asVolume(this.entity);
|
||||
this.readingTime.minHours = vol.minHoursToRead;
|
||||
this.readingTime.maxHours = vol.maxHoursToRead;
|
||||
this.readingTime.avgHours = vol.avgHoursToRead;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
<ng-container [ngSwitch]="libraryType">
|
||||
<ng-container *ngSwitchCase="LibraryType.Comic">
|
||||
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullComicTitle">
|
||||
{{titleName}}
|
||||
</ng-container>
|
||||
<ng-template #fullComicTitle>
|
||||
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
|
||||
<ng-container *ngIf="includeVolume && volumeTitle != ''">
|
||||
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
</ng-container>
|
||||
{{entity.number != 0 ? (isChapter ? 'Issue #' + entity.number : volumeTitle) : 'Special'}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LibraryType.Manga">
|
||||
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullMangaTitle">
|
||||
{{titleName}}
|
||||
</ng-container>
|
||||
<ng-template #fullMangaTitle>
|
||||
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
|
||||
<ng-container *ngIf="includeVolume && volumeTitle != ''">
|
||||
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
</ng-container>
|
||||
{{entity.number != 0 ? (isChapter ? 'Chapter ' + entity.number : volumeTitle) : 'Special'}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LibraryType.Book">
|
||||
{{volumeTitle}}
|
||||
</ng-container>
|
||||
</ng-container>
|
57
UI/Web/src/app/cards/entity-title/entity-title.component.ts
Normal file
57
UI/Web/src/app/cards/entity-title/entity-title.component.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-title',
|
||||
templateUrl: './entity-title.component.html',
|
||||
styleUrls: ['./entity-title.component.scss']
|
||||
})
|
||||
export class EntityTitleComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Library type for which the entity belongs
|
||||
*/
|
||||
@Input() libraryType: LibraryType = LibraryType.Manga;
|
||||
@Input() seriesName: string = '';
|
||||
@Input() entity!: Volume | Chapter;
|
||||
/**
|
||||
* When generating the title, should this prepend 'Volume number' before the Chapter wording
|
||||
*/
|
||||
@Input() includeVolume: boolean = false;
|
||||
/**
|
||||
* When a titleName (aka a title) is avaliable on the entity, show it over Volume X Chapter Y
|
||||
*/
|
||||
@Input() prioritizeTitleName: boolean = true;
|
||||
|
||||
isChapter = false;
|
||||
chapter!: Chapter;
|
||||
titleName: string = '';
|
||||
volumeTitle: string = '';
|
||||
|
||||
|
||||
get LibraryType() {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
constructor(private utilityService: UtilityService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.entity);
|
||||
|
||||
if (this.isChapter) {
|
||||
const c = (this.entity as Chapter);
|
||||
this.volumeTitle = c.volumeTitle || '';
|
||||
this.titleName = c.titleName || '';
|
||||
} else {
|
||||
const v = this.utilityService.asVolume(this.entity);
|
||||
this.volumeTitle = v.name || '';
|
||||
this.titleName = v.chapters[0].titleName || '';
|
||||
}
|
||||
}
|
||||
}
|
40
UI/Web/src/app/cards/list-item/list-item.component.html
Normal file
40
UI/Web/src/app/cards/list-item/list-item.component.html
Normal file
@ -0,0 +1,40 @@
|
||||
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
|
||||
<div class="pe-2">
|
||||
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [width]="imageWidth"></app-image>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="g-0">
|
||||
<h5 style="margin-bottom: 0px">
|
||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="seriesName" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<ng-content select="[title]"></ng-content>
|
||||
<button class="btn btn-primary float-end" (click)="read.emit()">
|
||||
<span>
|
||||
<i class="fa fa-book me-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="d-none d-sm-inline-block">Read</span>
|
||||
</button>
|
||||
</h5>
|
||||
<!-- This isn't perfect, but it might work. TODO: Polish this-->
|
||||
<h6 class="text-muted" [ngClass]="{'subtitle-with-actionables' : actions.length > 0}" style="font-size: 0.75rem" *ngIf="Title != '' && showTitle">{{Title}}</h6>
|
||||
<ng-container *ngIf="summary.length > 0">
|
||||
<div class="mt-2 ps-2">
|
||||
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="ps-2 d-none d-md-inline-block">
|
||||
<app-entity-info-cards [entity]="entity" [showExtendedProperties]="false"></app-entity-info-cards>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
26
UI/Web/src/app/cards/list-item/list-item.component.scss
Normal file
26
UI/Web/src/app/cards/list-item/list-item.component.scss
Normal file
@ -0,0 +1,26 @@
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: 55px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.progress-banner {
|
||||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: var(--card-progress-bar-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-container {
|
||||
background: rgb(0,0,0);
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
}
|
140
UI/Web/src/app/cards/list-item/list-item.component.ts
Normal file
140
UI/Web/src/app/cards/list-item/list-item.component.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-item',
|
||||
templateUrl: './list-item.component.html',
|
||||
styleUrls: ['./list-item.component.scss']
|
||||
})
|
||||
export class ListItemComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Volume or Chapter to render
|
||||
*/
|
||||
@Input() entity!: Volume | Chapter;
|
||||
/**
|
||||
* Image to show
|
||||
*/
|
||||
@Input() imageUrl: string = '';
|
||||
/**
|
||||
* Actions to show
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = []; // Volume | Chapter
|
||||
/**
|
||||
* Library type to help with formatting title
|
||||
*/
|
||||
@Input() libraryType: LibraryType = LibraryType.Manga;
|
||||
/**
|
||||
* Name of the Series to show under the title
|
||||
*/
|
||||
@Input() seriesName: string = '';
|
||||
|
||||
/**
|
||||
* Size of the Image Height. Defaults to 230px.
|
||||
*/
|
||||
@Input() imageHeight: string = '230px';
|
||||
/**
|
||||
* Size of the Image Width Defaults to 158px.
|
||||
*/
|
||||
@Input() imageWidth: string = '158px';
|
||||
@Input() seriesLink: string = '';
|
||||
|
||||
@Input() pagesRead: number = 0;
|
||||
@Input() totalPages: number = 0;
|
||||
|
||||
@Input() relation: RelationKind | undefined = undefined;
|
||||
|
||||
/**
|
||||
* When generating the title, should this prepend 'Volume number' before the Chapter wording
|
||||
*/
|
||||
@Input() includeVolume: boolean = false;
|
||||
/**
|
||||
* Show's the title if avaible on entity
|
||||
*/
|
||||
@Input() showTitle: boolean = true;
|
||||
|
||||
@Output() read: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
actionInProgress: boolean = false;
|
||||
summary$: Observable<string> = of('');
|
||||
summary: string = '';
|
||||
isChapter: boolean = false;
|
||||
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
get Title() {
|
||||
if (this.isChapter) return (this.entity as Chapter).titleName;
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
constructor(private utilityService: UtilityService, private downloadService: DownloadService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.isChapter = this.utilityService.isChapter(this.entity);
|
||||
if (this.isChapter) {
|
||||
this.summary = this.utilityService.asChapter(this.entity).summary || '';
|
||||
} else {
|
||||
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
|
||||
}
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
if (this.downloadInProgress === true) {
|
||||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.utilityService.isVolume(this.entity)) {
|
||||
const volume = this.utilityService.asVolume(this.entity);
|
||||
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
} else if (this.utilityService.isChapter(this.entity)) {
|
||||
const chapter = this.utilityService.asChapter(this.entity);
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, this.entity);
|
||||
}
|
||||
}
|
||||
}
|
@ -72,7 +72,8 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
ngOnChanges(changes: any) {
|
||||
if (this.data) {
|
||||
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
//this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id); // TODO: Do I need to do this since image now handles updates?
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,103 @@
|
||||
<div class="row g-0 mb-4 mt-3">
|
||||
|
||||
<ng-container *ngIf="seriesMetadata">
|
||||
<ng-container *ngIf="seriesMetadata.ageRating">
|
||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
|
||||
<app-icon-and-title label="Age Rating" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" title="Age Rating">
|
||||
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
|
||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
|
||||
<app-icon-and-title label="Release Year" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
|
||||
{{seriesMetadata.releaseYear}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="seriesMetadata.language !== null">
|
||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
|
||||
<app-icon-and-title label="Language" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterQueryParam.Languages, seriesMetadata.language)" title="Language">
|
||||
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container>
|
||||
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
|
||||
<app-icon-and-title label="Publication" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === 'Ongoing' ? 'empty' : 'end'}}" (click)="handleGoTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
|
||||
{{pubStatus}}
|
||||
</app-icon-and-title>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="vr m-2 d-none d-lg-block"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="series">
|
||||
<ng-container>
|
||||
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<app-icon-and-title label="Format" [clickable]="true" [fontClasses]="'fa ' + utilityService.mangaFormatIcon(series.format)" (click)="handleGoTo(FilterQueryParam.Format, series.format)" title="Format">
|
||||
{{utilityService.mangaFormat(series.format)}}
|
||||
</app-icon-and-title>
|
||||
</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="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<app-icon-and-title label="Last Read" [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
|
||||
{{series.latestReadDate | date:'shortDate'}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<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 label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
|
||||
{{series.wordCount | compactNumber}} Words
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></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 label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines">
|
||||
{{series.pages | number:''}} Pages
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
|
||||
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
|
||||
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
|
||||
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
|
||||
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && 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 label="Read Left" [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>
|
@ -0,0 +1,55 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesMetadata } from 'src/app/_models/series-metadata';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-info-cards',
|
||||
templateUrl: './series-info-cards.component.html',
|
||||
styleUrls: ['./series-info-cards.component.scss']
|
||||
})
|
||||
export class SeriesInfoCardsComponent implements OnInit {
|
||||
|
||||
@Input() series!: Series;
|
||||
@Input() seriesMetadata!: SeriesMetadata;
|
||||
@Input() hasReadingProgress: boolean = false;
|
||||
@Input() readingTimeLeft: HourEstimateRange | undefined;
|
||||
/**
|
||||
* If this should make an API call to request readingTimeLeft
|
||||
*/
|
||||
@Input() showReadingTimeLeft: boolean = true;
|
||||
@Output() goTo: EventEmitter<{queryParamName: FilterQueryParam, filter: any}> = new EventEmitter();
|
||||
|
||||
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
|
||||
|
||||
get MangaFormat() {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get FilterQueryParam() {
|
||||
return FilterQueryParam;
|
||||
}
|
||||
|
||||
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private readerService: ReaderService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.series !== null) {
|
||||
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
|
||||
this.readingTime.minHours = this.series.minHoursToRead;
|
||||
this.readingTime.maxHours = this.series.maxHoursToRead;
|
||||
this.readingTime.avgHours = this.series.avgHoursToRead;
|
||||
}
|
||||
}
|
||||
|
||||
handleGoTo(queryParamName: FilterQueryParam, filter: any) {
|
||||
this.goTo.emit({queryParamName, filter});
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user