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:
Joseph Milazzo 2022-06-13 16:37:49 -05:00 committed by GitHub
parent f0f0e23e88
commit bbc48a5f5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 7863 additions and 2097 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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;

View File

@ -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)

View File

@ -181,7 +181,7 @@ namespace API.Controllers
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
return Ok();
}

View File

@ -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");

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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

View File

@ -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; }
}
}

View File

@ -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;
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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")))

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -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");

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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; }

View File

@ -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>

View File

@ -7,10 +7,6 @@
/// </summary>
Other = 1,
/// <summary>
/// Artist
/// </summary>
//Artist = 2,
/// <summary>
/// Author or Writer
/// </summary>
Writer = 3,

View File

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace API.Entities.Enums.UserPreferences;
public enum PageLayoutMode
{
[Description("Cards")]
Cards = 0,
[Description("List")]
List = 1
}

View 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; }
}

View File

@ -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; }
}

View File

@ -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; }
}
}

View File

@ -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>();

View File

@ -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>

View File

@ -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;
}
}
}
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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
{

View File

@ -724,7 +724,7 @@ namespace API.Services
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception ex)
catch (Exception)
{
ClearAndDeleteDirectory(directoryName);
return false;

View File

@ -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)

View File

@ -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

View File

@ -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))
};
}
}

View File

@ -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
};
}

View File

@ -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));

View File

@ -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)

View File

@ -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)

View File

@ -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;

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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;
}

View File

@ -2,5 +2,5 @@ export interface HourEstimateRange{
minHours: number;
maxHours: number;
avgHours: number;
hasProgress: boolean;
//hasProgress: boolean;
}

View 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
}

View File

@ -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}];

View File

@ -52,4 +52,7 @@ export interface Series {
* Number of words in the series
*/
wordCount: number;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}

View File

@ -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;
}

View File

@ -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 {
}
});
}
}

View File

@ -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,

View File

@ -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)) {

View File

@ -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
*/

View File

@ -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>

View File

@ -8,6 +8,7 @@
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[trackByIdentity]="trackByIdentity"
[pagination]="pagination"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"

View File

@ -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}`;
}

View File

@ -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({

View File

@ -33,6 +33,7 @@ export class AppComponent implements OnInit {
this.ngbModal.dismissAll();
}
});
}
@HostListener('window:resize', ['$event'])

View File

@ -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>

View File

@ -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"

View File

@ -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,

View File

@ -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) {

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
});
}
}

View File

@ -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
}
}

View File

@ -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>

View File

@ -14,3 +14,7 @@
overflow: auto;
height: calc(40vh - 63px); // drawer height - offcanvas heading height
}
.h6 {
font-weight: 600;
}

View File

@ -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';
}),

View File

@ -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>

View File

@ -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);
}

View File

@ -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)

View File

@ -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>

View File

@ -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();

View File

@ -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 { }

View File

@ -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>

View File

@ -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 != '') {

View File

@ -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">&lt;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>

View File

@ -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();
}
}

View File

@ -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>

View 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 || '';
}
}
}

View 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>

View 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;
}

View 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);
}
}
}

View File

@ -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?
}
}

View File

@ -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>

View File

@ -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