Reading List Detail Overhaul + More Bugfixes and Polish (#3687)

Co-authored-by: Yongun Seong <yseong.p@gmail.com>
This commit is contained in:
Joe Milazzo 2025-03-29 19:47:53 -05:00 committed by GitHub
parent b2ee651fb8
commit dad212bfb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 5056 additions and 729 deletions

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.Entities; using API.Entities;
using API.Helpers; using API.Helpers;
@ -49,17 +50,14 @@ public class OrderableHelperTests
[Fact] [Fact]
public void ReorderItems_InvalidPosition_NoChange() public void ReorderItems_InvalidPosition_NoChange()
{ {
// Arrange
var items = new List<AppUserSideNavStream> var items = new List<AppUserSideNavStream>
{ {
new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" },
new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" },
}; };
// Act
OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range
// Assert
Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0 Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0
Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1 Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1
} }
@ -80,7 +78,6 @@ public class OrderableHelperTests
[Fact] [Fact]
public void ReorderItems_DoubleMove() public void ReorderItems_DoubleMove()
{ {
// Arrange
var items = new List<AppUserSideNavStream> var items = new List<AppUserSideNavStream>
{ {
new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" },
@ -94,7 +91,6 @@ public class OrderableHelperTests
// Move 4 -> 1 // Move 4 -> 1
OrderableHelper.ReorderItems(items, 5, 1); OrderableHelper.ReorderItems(items, 5, 1);
// Assert
Assert.Equal(1, items[0].Id); Assert.Equal(1, items[0].Id);
Assert.Equal(0, items[0].Order); Assert.Equal(0, items[0].Order);
Assert.Equal(5, items[1].Id); Assert.Equal(5, items[1].Id);
@ -109,4 +105,98 @@ public class OrderableHelperTests
Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); Assert.Equal("034125", string.Join("", items.Select(s => s.Name)));
} }
private static List<ReadingListItem> CreateTestReadingListItems(int count = 4)
{
var items = new List<ReadingListItem>();
for (var i = 0; i < count; i++)
{
items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1});
}
return items;
}
[Fact]
public void ReorderItems_MoveItemToBeginning_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 3, 0);
Assert.Equal(3, items[0].Id);
Assert.Equal(1, items[1].Id);
Assert.Equal(2, items[2].Id);
Assert.Equal(4, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToEnd_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 1, 3);
Assert.Equal(2, items[0].Id);
Assert.Equal(3, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(1, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToMiddle_CorrectOrder()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 4, 2);
Assert.Equal(1, items[0].Id);
Assert.Equal(2, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(3, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd()
{
var items = CreateTestReadingListItems();
OrderableHelper.ReorderItems(items, 2, 10);
Assert.Equal(1, items[0].Id);
Assert.Equal(3, items[1].Id);
Assert.Equal(4, items[2].Id);
Assert.Equal(2, items[3].Id);
for (var i = 0; i < items.Count; i++)
{
Assert.Equal(i, items[i].Order);
}
}
[Fact]
public void ReorderItems_NegativePosition_ThrowsArgumentException()
{
var items = CreateTestReadingListItems();
Assert.Throws<ArgumentException>(() =>
OrderableHelper.ReorderItems(items, 2, -1)
);
}
} }

View File

@ -10,6 +10,10 @@ public class StringHelperTests
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /> <br><br><br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>", "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> <br><br><br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>",
"<p>A Perfect Marriage Becomes a Perfect Affair!<br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>" "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>"
)] )]
[InlineData(
"<p><a href=\"https://blog.goo.ne.jp/tamakiya_web\">Blog</a> | <a href=\"https://twitter.com/tamakinozomu\">Twitter</a> | <a href=\"https://www.pixiv.net/member.php?id=68961\">Pixiv</a> | <a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>",
"<p><a href=\"https://blog.goo.ne.jp/tamakiya_web\">Blog</a> | <a href=\"https://twitter.com/tamakinozomu\">Twitter</a> | <a href=\"https://www.pixiv.net/member.php?id=68961\">Pixiv</a> | <a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>"
)]
public void TestSquashBreaklines(string input, string expected) public void TestSquashBreaklines(string input, string expected)
{ {
Assert.Equal(expected, StringHelper.SquashBreaklines(input)); Assert.Equal(expected, StringHelper.SquashBreaklines(input));
@ -28,4 +32,15 @@ public class StringHelperTests
{ {
Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input));
} }
[Theory]
[InlineData(
"""<a href=\"https://pawoo.net/&amp;#64;tamakiya\">Pawoo</a></p>""",
"""<a href=\"https://pawoo.net/@tamakiya\">Pawoo</a></p>"""
)]
public void TestCorrectUrls(string input, string expected)
{
Assert.Equal(expected, StringHelper.CorrectUrls(input));
}
} }

View File

@ -213,7 +213,6 @@ public class LibraryController : BaseApiController
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username);
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
return Ok(ret); return Ok(ret);
} }
@ -419,8 +418,7 @@ public class LibraryController : BaseApiController
.Distinct() .Distinct()
.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath);
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]);
new List<string>() {dto.FolderPath});
_taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath);

View File

@ -46,8 +46,8 @@ public class LocaleController : BaseApiController
} }
var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f);
await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(7)); await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1));
return Ok(); return Ok(ret);
} }
} }

View File

@ -803,7 +803,7 @@ public class ReaderController : BaseApiController
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("time-left")] [HttpGet("time-left")]
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId"])] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId) public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();

View File

@ -6,6 +6,7 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
@ -23,13 +24,15 @@ public class ReadingListController : BaseApiController
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IReadingListService _readingListService; private readonly IReadingListService _readingListService;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IReaderService _readerService;
public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService,
ILocalizationService localizationService) ILocalizationService localizationService, IReaderService readerService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_readingListService = readingListService; _readingListService = readingListService;
_localizationService = localizationService; _localizationService = localizationService;
_readerService = readerService;
} }
/// <summary> /// <summary>
@ -128,7 +131,7 @@ public class ReadingListController : BaseApiController
} }
/// <summary> /// <summary>
/// Deletes a list item from the list. Will reorder all item positions afterwards /// Deletes a list item from the list. Item orders will update as a result.
/// </summary> /// </summary>
/// <param name="dto"></param> /// <param name="dto"></param>
/// <returns></returns> /// <returns></returns>
@ -452,26 +455,38 @@ public class ReadingListController : BaseApiController
return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
} }
/// <summary> /// <summary>
/// Returns a list of characters associated with the reading list /// Returns a list of a given role associated with the reading list
/// </summary>
/// <param name="readingListId"></param>
/// <param name="role">PersonRole</param>
/// <returns></returns>
[HttpGet("people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])]
public ActionResult<IEnumerable<PersonDto>> GetPeopleByRoleForList(int readingListId, PersonRole role)
{
return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role));
}
/// <summary>
/// Returns all people in given roles for a reading list
/// </summary> /// </summary>
/// <param name="readingListId"></param> /// <param name="readingListId"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("characters")] [HttpGet("all-people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])]
public ActionResult<IEnumerable<PersonDto>> GetCharactersForList(int readingListId) public async Task<ActionResult<IEnumerable<PersonDto>>> GetAllPeopleForList(int readingListId)
{ {
return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId));
} }
/// <summary> /// <summary>
/// Returns the next chapter within the reading list /// Returns the next chapter within the reading list
/// </summary> /// </summary>
/// <param name="currentChapterId"></param> /// <param name="currentChapterId"></param>
/// <param name="readingListId"></param> /// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns> /// <returns>Chapter ID for next item, -1 if nothing exists</returns>
[HttpGet("next-chapter")] [HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId) public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId)
{ {
@ -577,4 +592,26 @@ public class ReadingListController : BaseApiController
return Ok(); return Ok();
} }
/// <summary>
/// Returns random information about a Reading List
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])]
public async Task<ActionResult<ReadingListInfoDto?>> GetReadingListInfo(int readingListId)
{
var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId);
if (result == null) return Ok(null);
var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub);
result.MinHoursToRead = timeEstimate.MinHours;
result.AvgHoursToRead = timeEstimate.AvgHours;
result.MaxHoursToRead = timeEstimate.MaxHours;
return Ok(result);
}
} }

View File

@ -238,7 +238,8 @@ public class SeriesController : BaseApiController
// Trigger a refresh when we are moving from a locked image to a non-locked // Trigger a refresh when we are moving from a locked image to a non-locked
needsRefreshMetadata = true; needsRefreshMetadata = true;
series.CoverImage = null; series.CoverImage = null;
series.CoverImageLocked = updateSeries.CoverImageLocked; series.CoverImageLocked = false;
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
series.ResetColorScape(); series.ResetColorScape();
} }

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists;
public class ReadingListCast
{
public ICollection<PersonDto> Writers { get; set; } = [];
public ICollection<PersonDto> CoverArtists { get; set; } = [];
public ICollection<PersonDto> Publishers { get; set; } = [];
public ICollection<PersonDto> Characters { get; set; } = [];
public ICollection<PersonDto> Pencillers { get; set; } = [];
public ICollection<PersonDto> Inkers { get; set; } = [];
public ICollection<PersonDto> Imprints { get; set; } = [];
public ICollection<PersonDto> Colorists { get; set; } = [];
public ICollection<PersonDto> Letterers { get; set; } = [];
public ICollection<PersonDto> Editors { get; set; } = [];
public ICollection<PersonDto> Translators { get; set; } = [];
public ICollection<PersonDto> Teams { get; set; } = [];
public ICollection<PersonDto> Locations { get; set; } = [];
}

View File

@ -1,4 +1,5 @@
using System; using System;
using API.Entities.Enums;
using API.Entities.Interfaces; using API.Entities.Interfaces;
namespace API.DTOs.ReadingLists; namespace API.DTOs.ReadingLists;
@ -43,6 +44,10 @@ public class ReadingListDto : IHasCoverImage
/// Maximum Month the Reading List starts /// Maximum Month the Reading List starts
/// </summary> /// </summary>
public int EndingMonth { get; set; } public int EndingMonth { get; set; }
/// <summary>
/// The highest age rating from all Series within the reading list
/// </summary>
public required AgeRating AgeRating { get; set; } = AgeRating.Unknown;
public void ResetColorScape() public void ResetColorScape()
{ {

View File

@ -0,0 +1,26 @@
using API.DTOs.Reader;
using API.Entities.Interfaces;
namespace API.DTOs.ReadingLists;
public class ReadingListInfoDto : IHasReadTimeEstimate
{
/// <summary>
/// Total Pages across all Reading List Items
/// </summary>
public int Pages { get; set; }
/// <summary>
/// Total Word count across all Reading List Items
/// </summary>
public long WordCount { get; set; }
/// <summary>
/// Are ALL Reading List Items epub
/// </summary>
public bool IsAllEpub { 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 float AvgHoursToRead { get; set; }
}

View File

@ -25,7 +25,7 @@ public class ReadingListItemDto
/// <summary> /// <summary>
/// Release Date from Chapter /// Release Date from Chapter
/// </summary> /// </summary>
public DateTime ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
/// <summary> /// <summary>
/// Used internally only /// Used internally only
/// </summary> /// </summary>
@ -33,7 +33,7 @@ public class ReadingListItemDto
/// <summary> /// <summary>
/// The last time a reading list item (underlying chapter) was read by current authenticated user /// The last time a reading list item (underlying chapter) was read by current authenticated user
/// </summary> /// </summary>
public DateTime LastReadingProgressUtc { get; set; } public DateTime? LastReadingProgressUtc { get; set; }
/// <summary> /// <summary>
/// File size of underlying item /// File size of underlying item
/// </summary> /// </summary>

View File

@ -63,6 +63,13 @@ public class UserPreferencesDto
/// </summary> /// </summary>
[Required] [Required]
public bool ShowScreenHints { get; set; } = true; public bool ShowScreenHints { get; set; } = true;
/// <summary>
/// Manga Reader Option: Allow Automatic Webtoon detection
/// </summary>
[Required]
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
/// <summary> /// <summary>
/// Book Reader Option: Override extra Margin /// Book Reader Option: Override extra Margin
/// </summary> /// </summary>

View File

@ -133,6 +133,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserPreferences>() builder.Entity<AppUserPreferences>()
.Property(b => b.WantToReadSync) .Property(b => b.WantToReadSync)
.HasDefaultValue(true); .HasDefaultValue(true);
builder.Entity<AppUserPreferences>()
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
.HasDefaultValue(true);
builder.Entity<Library>() builder.Entity<Library>()
.Property(b => b.AllowScrobbling) .Property(b => b.AllowScrobbling)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AutomaticWebtoonReaderMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowAutomaticWebtoonReaderDetection",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowAutomaticWebtoonReaderDetection",
table: "AppUserPreferences");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.Entity("API.Entities.AppRole", b => modelBuilder.Entity("API.Entities.AppRole", b =>
{ {
@ -353,6 +353,11 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("AllowAutomaticWebtoonReaderDetection")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AniListScrobblingEnabled") b.Property<bool>("AniListScrobblingEnabled")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@ -911,24 +916,6 @@ namespace API.Data.Migrations
b.ToTable("Chapter"); b.ToTable("Chapter");
}); });
modelBuilder.Entity("API.Entities.ChapterPeople", b =>
{
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("ChapterId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("ChapterPeople");
});
modelBuilder.Entity("API.Entities.CollectionTag", b => modelBuilder.Entity("API.Entities.CollectionTag", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -1640,7 +1627,7 @@ namespace API.Data.Migrations
b.ToTable("MetadataFieldMapping"); b.ToTable("MetadataFieldMapping");
}); });
modelBuilder.Entity("API.Entities.MetadataSettings", b => modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@ -1703,7 +1690,25 @@ namespace API.Data.Migrations
b.ToTable("MetadataSettings"); b.ToTable("MetadataSettings");
}); });
modelBuilder.Entity("API.Entities.Person", b => modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
{
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("ChapterId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("ChapterPeople");
});
modelBuilder.Entity("API.Entities.Person.Person", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@ -1747,6 +1752,32 @@ namespace API.Data.Migrations
b.ToTable("Person"); b.ToTable("Person");
}); });
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{
b.Property<int>("SeriesMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.HasKey("SeriesMetadataId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople");
});
modelBuilder.Entity("API.Entities.ReadingList", b => modelBuilder.Entity("API.Entities.ReadingList", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -2111,32 +2142,6 @@ namespace API.Data.Migrations
b.ToTable("Series"); b.ToTable("Series");
}); });
modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b =>
{
b.Property<int>("SeriesMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.HasKey("SeriesMetadataId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople");
});
modelBuilder.Entity("API.Entities.ServerSetting", b => modelBuilder.Entity("API.Entities.ServerSetting", b =>
{ {
b.Property<int>("Key") b.Property<int>("Key")
@ -2804,25 +2809,6 @@ namespace API.Data.Migrations
b.Navigation("Volume"); b.Navigation("Volume");
}); });
modelBuilder.Entity("API.Entities.ChapterPeople", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("People")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Person", "Person")
.WithMany("ChapterPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Person");
});
modelBuilder.Entity("API.Entities.Device", b => modelBuilder.Entity("API.Entities.Device", b =>
{ {
b.HasOne("API.Entities.AppUser", "AppUser") b.HasOne("API.Entities.AppUser", "AppUser")
@ -2943,7 +2929,7 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
{ {
b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings")
.WithMany("FieldMappings") .WithMany("FieldMappings")
.HasForeignKey("MetadataSettingsId") .HasForeignKey("MetadataSettingsId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@ -2952,6 +2938,44 @@ namespace API.Data.Migrations
b.Navigation("MetadataSettings"); b.Navigation("MetadataSettings");
}); });
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("People")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Person.Person", "Person")
.WithMany("ChapterPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Person");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{
b.HasOne("API.Entities.Person.Person", "Person")
.WithMany("SeriesMetadataPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata")
.WithMany("People")
.HasForeignKey("SeriesMetadataId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ReadingList", b => modelBuilder.Entity("API.Entities.ReadingList", b =>
{ {
b.HasOne("API.Entities.AppUser", "AppUser") b.HasOne("API.Entities.AppUser", "AppUser")
@ -3072,25 +3096,6 @@ namespace API.Data.Migrations
b.Navigation("Library"); b.Navigation("Library");
}); });
modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b =>
{
b.HasOne("API.Entities.Person", "Person")
.WithMany("SeriesMetadataPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata")
.WithMany("People")
.HasForeignKey("SeriesMetadataId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.Volume", b => modelBuilder.Entity("API.Entities.Volume", b =>
{ {
b.HasOne("API.Entities.Series", "Series") b.HasOne("API.Entities.Series", "Series")
@ -3351,12 +3356,12 @@ namespace API.Data.Migrations
b.Navigation("People"); b.Navigation("People");
}); });
modelBuilder.Entity("API.Entities.MetadataSettings", b => modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
{ {
b.Navigation("FieldMappings"); b.Navigation("FieldMappings");
}); });
modelBuilder.Entity("API.Entities.Person", b => modelBuilder.Entity("API.Entities.Person.Person", b =>
{ {
b.Navigation("ChapterPeople"); b.Navigation("ChapterPeople");

View File

@ -1,10 +1,7 @@
using System.Collections;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Person; using API.Entities.Person;
using API.Extensions; using API.Extensions;
@ -31,15 +28,13 @@ public interface IPersonRepository
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
Task RemoveAllPeopleNoLongerAssociated(); Task RemoveAllPeopleNoLongerAssociated();
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null); Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null);
Task<int> GetCountAsync();
Task<string> GetCoverImageAsync(int personId); Task<string?> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageByNameAsync(string name); Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId); Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams); Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
Task<Person?> GetPersonById(int personId); Task<Person?> GetPersonById(int personId);
Task<PersonDto?> GetPersonDtoByName(string name, int userId); Task<PersonDto?> GetPersonDtoByName(string name, int userId);
Task<Person> GetPersonByName(string name);
Task<bool> IsNameUnique(string name); Task<bool> IsNameUnique(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId); Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
@ -126,12 +121,8 @@ public class PersonRepository : IPersonRepository
.ToListAsync(); .ToListAsync();
} }
public async Task<int> GetCountAsync()
{
return await _context.Person.CountAsync();
}
public async Task<string> GetCoverImageAsync(int personId) public async Task<string?> GetCoverImageAsync(int personId)
{ {
return await _context.Person return await _context.Person
.Where(c => c.Id == personId) .Where(c => c.Id == personId)
@ -139,7 +130,7 @@ public class PersonRepository : IPersonRepository
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
} }
public async Task<string> GetCoverImageByNameAsync(string name) public async Task<string?> GetCoverImageByNameAsync(string name)
{ {
var normalized = name.ToNormalized(); var normalized = name.ToNormalized();
return await _context.Person return await _context.Person
@ -208,7 +199,7 @@ public class PersonRepository : IPersonRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<PersonDto> GetPersonDtoByName(string name, int userId) public async Task<PersonDto?> GetPersonDtoByName(string name, int userId)
{ {
var normalized = name.ToNormalized(); var normalized = name.ToNormalized();
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -220,11 +211,6 @@ public class PersonRepository : IPersonRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<Person> GetPersonByName(string name)
{
return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized());
}
public async Task<bool> IsNameUnique(string name) public async Task<bool> IsNameUnique(string name)
{ {
return !(await _context.Person.AnyAsync(p => p.Name == name)); return !(await _context.Person.AnyAsync(p => p.Name == name));

View File

@ -49,12 +49,14 @@ public interface IReadingListRepository
Task<IList<string>> GetRandomCoverImagesAsync(int readingListId); Task<IList<string>> GetRandomCoverImagesAsync(int readingListId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name); Task<bool> ReadingListExists(string name);
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId); IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role);
Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<int> RemoveReadingListsWithoutSeries(); Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items); Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId);
} }
public class ReadingListRepository : IReadingListRepository public class ReadingListRepository : IReadingListRepository
@ -121,12 +123,12 @@ public class ReadingListRepository : IReadingListRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
} }
public IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId) public IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role)
{ {
return _context.ReadingListItem return _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId) .Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People) .SelectMany(item => item.Chapter.People)
.Where(p => p.Role == PersonRole.Character) .Where(p => p.Role == role)
.OrderBy(p => p.Person.NormalizedName) .OrderBy(p => p.Person.NormalizedName)
.Select(p => p.Person) .Select(p => p.Person)
.Distinct() .Distinct()
@ -134,6 +136,77 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable(); .AsEnumerable();
} }
public async Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId)
{
var allPeople = await _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People)
.OrderBy(p => p.Person.NormalizedName)
.Select(p => new
{
Role = p.Role,
Person = _mapper.Map<PersonDto>(p.Person)
})
.Distinct()
.ToListAsync();
// Create the ReadingListCast object
var cast = new ReadingListCast();
// Group people by role and populate the appropriate collections
foreach (var personGroup in allPeople.GroupBy(p => p.Role))
{
var people = personGroup.Select(pg => pg.Person).ToList();
switch (personGroup.Key)
{
case PersonRole.Writer:
cast.Writers = people;
break;
case PersonRole.CoverArtist:
cast.CoverArtists = people;
break;
case PersonRole.Publisher:
cast.Publishers = people;
break;
case PersonRole.Character:
cast.Characters = people;
break;
case PersonRole.Penciller:
cast.Pencillers = people;
break;
case PersonRole.Inker:
cast.Inkers = people;
break;
case PersonRole.Imprint:
cast.Imprints = people;
break;
case PersonRole.Colorist:
cast.Colorists = people;
break;
case PersonRole.Letterer:
cast.Letterers = people;
break;
case PersonRole.Editor:
cast.Editors = people;
break;
case PersonRole.Translator:
cast.Translators = people;
break;
case PersonRole.Team:
cast.Teams = people;
break;
case PersonRole.Location:
cast.Locations = people;
break;
case PersonRole.Other:
break;
}
}
return cast;
}
public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension(); var extension = encodeFormat.GetExtension();
@ -181,6 +254,33 @@ public class ReadingListRepository : IReadingListRepository
.ToListAsync(); .ToListAsync();
} }
/// <summary>
/// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
public async Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId)
{
// Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub)
var readingList = await _context.ReadingList
.Where(rl => rl.Id == readingListId)
.Include(rl => rl.Items)
.ThenInclude(item => item.Series)
.Include(rl => rl.Items)
.ThenInclude(item => item.Volume)
.Include(rl => rl.Items)
.ThenInclude(item => item.Chapter)
.Select(rl => new ReadingListInfoDto()
{
WordCount = rl.Items.Sum(item => item.Chapter.WordCount),
Pages = rl.Items.Sum(item => item.Chapter.Pages),
IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub),
})
.FirstOrDefaultAsync();
return readingList;
}
public void Remove(ReadingListItem item) public void Remove(ReadingListItem item)
{ {

View File

@ -167,11 +167,11 @@ public class ScrobbleRepository : IScrobbleRepository
var query = _context.ScrobbleEvent var query = _context.ScrobbleEvent
.Where(e => e.AppUserId == userId) .Where(e => e.AppUserId == userId)
.Include(e => e.Series) .Include(e => e.Series)
.SortBy(filter.Field, filter.IsDescending)
.WhereIf(!string.IsNullOrEmpty(filter.Query), s => .WhereIf(!string.IsNullOrEmpty(filter.Query), s =>
EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") EF.Functions.Like(s.Series.Name, $"%{filter.Query}%")
) )
.WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review)
.SortBy(filter.Field, filter.IsDescending)
.AsSplitQuery() .AsSplitQuery()
.ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider); .ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider);

View File

@ -54,6 +54,10 @@ public class AppUserPreferences
/// Manga Reader Option: Should swiping trigger pagination /// Manga Reader Option: Should swiping trigger pagination
/// </summary> /// </summary>
public bool SwipeToPaginate { get; set; } public bool SwipeToPaginate { get; set; }
/// <summary>
/// Manga Reader Option: Allow Automatic Webtoon detection
/// </summary>
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
#endregion #endregion

View File

@ -7,5 +7,6 @@ public enum ScrobbleEventSortField
LastModified = 2, LastModified = 2,
Type= 3, Type= 3,
Series = 4, Series = 4,
IsProcessed = 5 IsProcessed = 5,
ScrobbleEventFilter = 6
} }

View File

@ -255,6 +255,7 @@ public static class QueryableExtensions
ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType),
ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName),
ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed),
ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType),
_ => query _ => query
}; };
} }
@ -267,6 +268,7 @@ public static class QueryableExtensions
ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType),
ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName),
ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed),
ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType),
_ => query _ => query
}; };
} }

View File

@ -14,6 +14,8 @@ public static partial class StringHelper
private static partial Regex BrMultipleRegex(); private static partial Regex BrMultipleRegex();
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex WhiteSpaceRegex(); private static partial Regex WhiteSpaceRegex();
[GeneratedRegex("&amp;#64;")]
private static partial Regex HtmlEncodedAtSymbolRegex();
#endregion #endregion
/// <summary> /// <summary>
@ -52,4 +54,16 @@ public static partial class StringHelper
return SourceRegex().Replace(description, string.Empty).Trim(); return SourceRegex().Replace(description, string.Empty).Trim();
} }
/// <summary>
/// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's
/// </summary>
/// <param name="description"></param>
/// <returns></returns>
public static string? CorrectUrls(string? description)
{
if (string.IsNullOrEmpty(description)) return description;
return HtmlEncodedAtSymbolRegex().Replace(description, "@");
}
} }

View File

@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService
{ {
if (string.IsNullOrEmpty(series.CoverImage)) continue; if (string.IsNullOrEmpty(series.CoverImage)) continue;
series.CoverImage = series.GetCoverImage(); series.CoverImage = series.GetCoverImage();
if (series.CoverImage == null)
{
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
}
_unitOfWork.SeriesRepository.Update(series); _unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
} }

View File

@ -199,6 +199,10 @@ public class MetadataService : IMetadataService
series.Volumes ??= []; series.Volumes ??= [];
series.CoverImage = series.GetCoverImage(); series.CoverImage = series.GetCoverImage();
if (series.CoverImage == null)
{
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
}
_imageService.UpdateColorScape(series); _imageService.UpdateColorScape(series);

View File

@ -76,7 +76,7 @@ public class ExternalMetadataService : IExternalMetadataService
}; };
// Allow 50 requests per 24 hours // Allow 50 requests per 24 hours
private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false);
static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$");
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper,
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
@ -115,18 +115,24 @@ public class ExternalMetadataService : IExternalMetadataService
// Find all Series that are eligible and limit // Find all Series that are eligible and limit
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false); var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false);
if (ids.Count == 0) return; if (ids.Count == 0) return;
ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true);
_logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids));
var count = 0; var count = 0;
var successfulMatches = new List<int>();
var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids);
foreach (var seriesId in ids) foreach (var seriesId in ids)
{ {
var libraryType = libTypes[seriesId]; var libraryType = libTypes[seriesId];
var success = await FetchSeriesMetadata(seriesId, libraryType); var success = await FetchSeriesMetadata(seriesId, libraryType);
if (success) count++; if (success)
{
count++;
successfulMatches.Add(seriesId);
}
await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request
} }
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches));
} }
@ -146,7 +152,7 @@ public class ExternalMetadataService : IExternalMetadataService
if (!RateLimiter.TryAcquire(string.Empty)) if (!RateLimiter.TryAcquire(string.Empty))
{ {
// Request not allowed due to rate limit // Request not allowed due to rate limit
_logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); _logger.LogInformation("Rate Limit hit for Kavita+ prefetch");
return false; return false;
} }
@ -731,7 +737,7 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
.Concat(series.Metadata.People .Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.Character) .Where(p => p.Role == PersonRole.Character)
@ -743,7 +749,9 @@ public class ExternalMetadataService : IExternalMetadataService
.ToList(); .ToList();
if (characters.Count == 0) return false; if (characters.Count == 0) return false;
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork);
foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character))
{ {
// Set a sort order based on their role // Set a sort order based on their role
@ -810,7 +818,7 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
.Concat(series.Metadata.People .Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.CoverArtist) .Where(p => p.Role == PersonRole.CoverArtist)
@ -867,7 +875,7 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
Name = w.Name, Name = w.Name,
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
.Concat(series.Metadata.People .Concat(series.Metadata.People
.Where(p => p.Role == PersonRole.Writer) .Where(p => p.Role == PersonRole.Writer)

View File

@ -552,14 +552,22 @@ public class CoverDbService : ICoverDbService
series.CoverImage = filePath; series.CoverImage = filePath;
series.CoverImageLocked = true; series.CoverImageLocked = true;
if (series.CoverImage == null)
{
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
}
_imageService.UpdateColorScape(series); _imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series); _unitOfWork.SeriesRepository.Update(series);
} }
} }
else else
{ {
series.CoverImage = string.Empty; series.CoverImage = null;
series.CoverImageLocked = false; series.CoverImageLocked = false;
if (series.CoverImage == null)
{
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
}
_imageService.UpdateColorScape(series); _imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series); _unitOfWork.SeriesRepository.Update(series);
} }

View File

@ -278,7 +278,8 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{ {
// Attempt to fetch from cache // Attempt to fetch from cache
var cachedReleases = await TryGetCachedReleases(); var cachedReleases = await TryGetCachedReleases();
if (cachedReleases != null) // If there is a cached release and the current version is within it, use it, otherwise regenerate
if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString())))
{ {
if (count > 0) if (count > 0)
{ {
@ -338,6 +339,29 @@ public partial class VersionUpdaterService : IVersionUpdaterService
return updateDtos; return updateDtos;
} }
/// <summary>
/// Compares 2 versions and ensures that the minor is always there
/// </summary>
/// <param name="v1"></param>
/// <param name="v2"></param>
/// <returns></returns>
private static bool IsVersionEqual(string v1, string v2)
{
var versionParts = v1.Split('.');
if (versionParts.Length < 4)
{
v1 += ".0"; // Append missing parts
}
versionParts = v2.Split('.');
if (versionParts.Length < 4)
{
v2 += ".0"; // Append missing parts
}
return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase);
}
private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases() private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases()
{ {
if (!File.Exists(_cacheFilePath)) return null; if (!File.Exists(_cacheFilePath)) return null;
@ -370,7 +394,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{ {
try try
{ {
var json = System.Text.Json.JsonSerializer.Serialize(updates, JsonOptions); var json = JsonSerializer.Serialize(updates, JsonOptions);
await File.WriteAllTextAsync(_cacheFilePath, json); await File.WriteAllTextAsync(_cacheFilePath, json);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -542,6 +542,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz",
"integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==", "integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==",
"dev": true,
"dependencies": { "dependencies": {
"@babel/core": "7.26.9", "@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14", "@jridgewell/sourcemap-codec": "^1.4.14",
@ -569,6 +570,7 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@ -583,6 +585,7 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": { "engines": {
"node": ">= 14.16.0" "node": ">= 14.16.0"
}, },
@ -4904,7 +4907,8 @@
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "8.3.6", "version": "8.3.6",
@ -5351,6 +5355,7 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"iconv-lite": "^0.6.2" "iconv-lite": "^0.6.2"
@ -5360,6 +5365,7 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8178,7 +8184,8 @@
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
}, },
"node_modules/replace-in-file": { "node_modules/replace-in-file": {
"version": "7.1.0", "version": "7.1.0",
@ -8399,7 +8406,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true "dev": true
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.85.0", "version": "1.85.0",
@ -8464,6 +8471,7 @@
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@ -9088,6 +9096,7 @@
"version": "5.5.4", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -1,12 +1,11 @@
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; import {PageLayoutMode} from '../page-layout-mode';
import { PageLayoutMode } from '../page-layout-mode'; import {PageSplitOption} from './page-split-option';
import { PageSplitOption } from './page-split-option'; import {ReaderMode} from './reader-mode';
import { ReaderMode } from './reader-mode'; import {ReadingDirection} from './reading-direction';
import { ReadingDirection } from './reading-direction'; import {ScalingOption} from './scaling-option';
import { ScalingOption } from './scaling-option'; import {SiteTheme} from './site-theme';
import { SiteTheme } from './site-theme';
import {WritingStyle} from "./writing-style"; import {WritingStyle} from "./writing-style";
import {PdfTheme} from "./pdf-theme"; import {PdfTheme} from "./pdf-theme";
import {PdfScrollMode} from "./pdf-scroll-mode"; import {PdfScrollMode} from "./pdf-scroll-mode";
@ -14,48 +13,49 @@ import {PdfLayoutMode} from "./pdf-layout-mode";
import {PdfSpreadMode} from "./pdf-spread-mode"; import {PdfSpreadMode} from "./pdf-spread-mode";
export interface Preferences { export interface Preferences {
// Manga Reader // Manga Reader
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
scalingOption: ScalingOption; scalingOption: ScalingOption;
pageSplitOption: PageSplitOption; pageSplitOption: PageSplitOption;
readerMode: ReaderMode; readerMode: ReaderMode;
autoCloseMenu: boolean; autoCloseMenu: boolean;
layoutMode: LayoutMode; layoutMode: LayoutMode;
backgroundColor: string; backgroundColor: string;
showScreenHints: boolean; showScreenHints: boolean;
emulateBook: boolean; emulateBook: boolean;
swipeToPaginate: boolean; swipeToPaginate: boolean;
allowAutomaticWebtoonReaderDetection: boolean;
// Book Reader // Book Reader
bookReaderMargin: number; bookReaderMargin: number;
bookReaderLineSpacing: number; bookReaderLineSpacing: number;
bookReaderFontSize: number; bookReaderFontSize: number;
bookReaderFontFamily: string; bookReaderFontFamily: string;
bookReaderTapToPaginate: boolean; bookReaderTapToPaginate: boolean;
bookReaderReadingDirection: ReadingDirection; bookReaderReadingDirection: ReadingDirection;
bookReaderWritingStyle: WritingStyle; bookReaderWritingStyle: WritingStyle;
bookReaderThemeName: string; bookReaderThemeName: string;
bookReaderLayoutMode: BookPageLayoutMode; bookReaderLayoutMode: BookPageLayoutMode;
bookReaderImmersiveMode: boolean; bookReaderImmersiveMode: boolean;
// PDF Reader // PDF Reader
pdfTheme: PdfTheme; pdfTheme: PdfTheme;
pdfScrollMode: PdfScrollMode; pdfScrollMode: PdfScrollMode;
pdfSpreadMode: PdfSpreadMode; pdfSpreadMode: PdfSpreadMode;
// Global // Global
theme: SiteTheme; theme: SiteTheme;
globalPageLayoutMode: PageLayoutMode; globalPageLayoutMode: PageLayoutMode;
blurUnreadSummaries: boolean; blurUnreadSummaries: boolean;
promptForDownloadSize: boolean; promptForDownloadSize: boolean;
noTransitions: boolean; noTransitions: boolean;
collapseSeriesRelationships: boolean; collapseSeriesRelationships: boolean;
shareReviews: boolean; shareReviews: boolean;
locale: string; locale: string;
// Kavita+ // Kavita+
aniListScrobblingEnabled: boolean; aniListScrobblingEnabled: boolean;
wantToReadSync: boolean; wantToReadSync: boolean;
} }
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];

View File

@ -1,6 +1,9 @@
import { LibraryType } from "./library/library"; import {LibraryType} from "./library/library";
import { MangaFormat } from "./manga-format"; import {MangaFormat} from "./manga-format";
import {IHasCover} from "./common/i-has-cover"; import {IHasCover} from "./common/i-has-cover";
import {AgeRating} from "./metadata/age-rating";
import {IHasReadingTime} from "./common/i-has-reading-time";
import {IHasCast} from "./common/i-has-cast";
export interface ReadingListItem { export interface ReadingListItem {
pagesRead: number; pagesRead: number;
@ -30,13 +33,25 @@ export interface ReadingList extends IHasCover {
items: Array<ReadingListItem>; items: Array<ReadingListItem>;
/** /**
* If this is empty or null, the cover image isn't set. Do not use this externally. * If this is empty or null, the cover image isn't set. Do not use this externally.
*/ */
coverImage?: string; coverImage?: string;
primaryColor: string; primaryColor: string;
secondaryColor: string; secondaryColor: string;
startingYear: number; startingYear: number;
startingMonth: number; startingMonth: number;
endingYear: number; endingYear: number;
endingMonth: number; endingMonth: number;
itemCount: number; itemCount: number;
ageRating: AgeRating;
} }
export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime {
pages: number;
wordCount: number;
isAllEpub: boolean;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}
export interface ReadingListCast extends IHasCast {}

View File

@ -4,7 +4,8 @@ export enum ScrobbleEventSortField {
LastModified = 2, LastModified = 2,
Type= 3, Type= 3,
Series = 4, Series = 4,
IsProcessed = 5 IsProcessed = 5,
ScrobbleEvent = 6
} }
export interface ScrobbleEventFilter { export interface ScrobbleEventFilter {

View File

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

View File

@ -1,19 +1,19 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; import {DestroyRef, Inject, inject, Injectable} from '@angular/core';
import {DOCUMENT, Location} from '@angular/common'; import {DOCUMENT, Location} from '@angular/common';
import { Router } from '@angular/router'; import {Router} from '@angular/router';
import { environment } from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import { ChapterInfo } from '../manga-reader/_models/chapter-info'; import {ChapterInfo} from '../manga-reader/_models/chapter-info';
import { Chapter } from '../_models/chapter'; import {Chapter} from '../_models/chapter';
import { HourEstimateRange } from '../_models/series-detail/hour-estimate-range'; import {HourEstimateRange} from '../_models/series-detail/hour-estimate-range';
import { MangaFormat } from '../_models/manga-format'; import {MangaFormat} from '../_models/manga-format';
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; import {BookmarkInfo} from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/readers/page-bookmark'; import {PageBookmark} from '../_models/readers/page-bookmark';
import { ProgressBookmark } from '../_models/readers/progress-bookmark'; import {ProgressBookmark} from '../_models/readers/progress-bookmark';
import { FileDimension } from '../manga-reader/_models/file-dimension'; import {FileDimension} from '../manga-reader/_models/file-dimension';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { TextResonse } from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import { AccountService } from './account.service'; import {AccountService} from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {PersonalToC} from "../_models/readers/personal-toc"; import {PersonalToC} from "../_models/readers/personal-toc";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
@ -23,7 +23,6 @@ import {Volume} from "../_models/volume";
import {UtilityService} from "../shared/_services/utility.service"; import {UtilityService} from "../shared/_services/utility.service";
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {getIosVersion, isSafari, Version} from "../_helpers/browser";
export const CHAPTER_ID_DOESNT_EXIST = -1; export const CHAPTER_ID_DOESNT_EXIST = -1;
@ -112,7 +111,6 @@ export class ReaderService {
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter); return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
} }
getBookmarks(chapterId: number) { getBookmarks(chapterId: number) {
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId); return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId);
} }

View File

@ -1,13 +1,13 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import {HttpClient, HttpParams} from '@angular/common/http';
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { map } from 'rxjs/operators'; import {map} from 'rxjs/operators';
import { environment } from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service'; import {UtilityService} from '../shared/_services/utility.service';
import { Person } from '../_models/metadata/person'; import {Person, PersonRole} from '../_models/metadata/person';
import { PaginatedResult } from '../_models/pagination'; import {PaginatedResult} from '../_models/pagination';
import { ReadingList, ReadingListItem } from '../_models/reading-list'; import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list';
import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary';
import { TextResonse } from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import {Action, ActionItem} from './action-factory.service'; import {Action, ActionItem} from './action-factory.service';
@Injectable({ @Injectable({
@ -20,7 +20,7 @@ export class ReadingListService {
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getReadingList(readingListId: number) { getReadingList(readingListId: number) {
return this.httpClient.get<ReadingList>(this.baseUrl + 'readinglist?readingListId=' + readingListId); return this.httpClient.get<ReadingList | null>(this.baseUrl + 'readinglist?readingListId=' + readingListId);
} }
getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) {
@ -114,10 +114,20 @@ export class ReadingListService {
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
} }
getCharacters(readingListId: number) { getPeople(readingListId: number, role: PersonRole) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); return this.httpClient.get<Array<Person>>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`);
} }
getAllPeople(readingListId: number) {
return this.httpClient.get<ReadingListCast>(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`);
}
getReadingListInfo(readingListId: number) {
return this.httpClient.get<ReadingListInfo>(this.baseUrl + `readinglist/info?readingListId=${readingListId}`);
}
promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) { promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) {
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
} }

View File

@ -6,6 +6,7 @@ import {filter, take} from "rxjs/operators";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component";
import {Router} from "@angular/router";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -15,6 +16,7 @@ export class VersionService implements OnDestroy{
private readonly serverService = inject(ServerService); private readonly serverService = inject(ServerService);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal); private readonly modalService = inject(NgbModal);
private readonly router = inject(Router);
public static readonly SERVER_VERSION_KEY = 'kavita--version'; public static readonly SERVER_VERSION_KEY = 'kavita--version';
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
@ -29,15 +31,23 @@ export class VersionService implements OnDestroy{
// Check intervals // Check intervals
private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours
private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date" private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date"
// Routes where version update modals should not be shown
private readonly EXCLUDED_ROUTES = [
'/manga/',
'/book/',
'/pdf/',
'/reader/'
];
private versionCheckSubscription?: Subscription; private versionCheckSubscription?: Subscription;
private outOfDateCheckSubscription?: Subscription; private outOfDateCheckSubscription?: Subscription;
private modalOpen = false; private modalOpen = false;
constructor() { constructor() {
this.startInitialVersionCheck();
this.startVersionCheck(); this.startVersionCheck();
this.startOutOfDateCheck(); this.startOutOfDateCheck();
} }
@ -47,6 +57,26 @@ export class VersionService implements OnDestroy{
this.outOfDateCheckSubscription?.unsubscribe(); this.outOfDateCheckSubscription?.unsubscribe();
} }
/**
* Initial version check to ensure localStorage is populated on first load
*/
private startInitialVersionCheck(): void {
this.accountService.currentUser$
.pipe(
filter(user => !!user),
take(1),
switchMap(user => this.serverService.getVersion(user!.apiKey))
)
.subscribe(serverVersion => {
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
// Always update localStorage on first load
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion);
});
}
/** /**
* Periodic check for server version to detect client refreshes and new updates * Periodic check for server version to detect client refreshes and new updates
*/ */
@ -76,12 +106,26 @@ export class VersionService implements OnDestroy{
.subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate));
} }
/**
* Checks if the current route is in the excluded routes list
*/
private isExcludedRoute(): boolean {
const currentUrl = this.router.url;
return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route));
}
/** /**
* Handles the version check response to determine if client refresh or new update notification is needed * Handles the version check response to determine if client refresh or new update notification is needed
*/ */
private handleVersionUpdate(serverVersion: string) { private handleVersionUpdate(serverVersion: string) {
if (this.modalOpen) return; if (this.modalOpen) return;
// Validate if we are on a reader route and if so, suppress
if (this.isExcludedRoute()) {
console.log('Version update blocked due to user reading');
return;
}
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);

View File

@ -28,12 +28,24 @@
</div> </div>
} }
<div class="mb-3 ms-1"> @if (ageRating) {
<h4 class="header">{{t('format-title')}}</h4> <div class="mb-3 ms-1">
<div class="ms-3"> <h4 class="header">{{t('age-rating-title')}}</h4>
<app-series-format [format]="format"></app-series-format> {{format | mangaFormat }} <div class="ms-3">
<app-age-rating-image [rating]="ageRating" />
</div>
</div> </div>
</div> }
@if (format) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('format-title')}}</h4>
<div class="ms-3">
<app-series-format [format]="format"></app-series-format> {{format | mangaFormat }}
</div>
</div>
}
<div class="setting-section-break" aria-hidden="true"></div> <div class="setting-section-break" aria-hidden="true"></div>

View File

@ -20,6 +20,8 @@ import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {LanguageNamePipe} from "../../_pipes/language-name.pipe"; import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
import {AsyncPipe} from "@angular/common"; import {AsyncPipe} from "@angular/common";
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
import {AgeRating} from "../../_models/metadata/age-rating";
import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component";
@Component({ @Component({
selector: 'app-details-tab', selector: 'app-details-tab',
@ -34,7 +36,8 @@ import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
MangaFormatPipe, MangaFormatPipe,
LanguageNamePipe, LanguageNamePipe,
AsyncPipe, AsyncPipe,
SafeUrlPipe SafeUrlPipe,
AgeRatingImageComponent
], ],
templateUrl: './details-tab.component.html', templateUrl: './details-tab.component.html',
styleUrl: './details-tab.component.scss', styleUrl: './details-tab.component.scss',
@ -47,11 +50,13 @@ export class DetailsTabComponent {
protected readonly PersonRole = PersonRole; protected readonly PersonRole = PersonRole;
protected readonly FilterField = FilterField; protected readonly FilterField = FilterField;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) metadata!: IHasCast; @Input({required: true}) metadata!: IHasCast;
@Input() readingTime: IHasReadingTime | undefined; @Input() readingTime: IHasReadingTime | undefined;
@Input() ageRating: AgeRating | undefined;
@Input() language: string | undefined; @Input() language: string | undefined;
@Input() format: MangaFormat = MangaFormat.UNKNOWN; @Input() format: MangaFormat | undefined;
@Input() releaseYear: number | undefined; @Input() releaseYear: number | undefined;
@Input() genres: Array<Genre> = []; @Input() genres: Array<Genre> = [];
@Input() tags: Array<Tag> = []; @Input() tags: Array<Tag> = [];
@ -62,6 +67,4 @@ export class DetailsTabComponent {
if (queryParamName === FilterField.None) return; if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
} }
protected readonly MangaFormat = MangaFormat;
} }

View File

@ -8,7 +8,7 @@
</div> </div>
<div class="ms-1"> <div class="ms-1">
<div><span class="title">{{item.series.name}}</span> <span class="me-1 float-end">({{item.matchRating | translocoPercent}})</span></div> <div><span class="title">{{item.series.name}}</span> <span class="me-1 float-end">({{item.matchRating | translocoPercent}})</span></div>
<div class="text-body-secondary"> <div class="text-muted">
@for(synm of item.series.synonyms; track synm; let last = $last) { @for(synm of item.series.synonyms; track synm; let last = $last) {
{{synm}} {{synm}}
@if (!last) { @if (!last) {

View File

@ -8,9 +8,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {PercentPipe} from "@angular/common";
import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
@ -18,18 +16,18 @@ import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component"; import {LoadingComponent} from "../../shared/loading/loading.component";
@Component({ @Component({
selector: 'app-match-series-result-item', selector: 'app-match-series-result-item',
imports: [ imports: [
ImageComponent, ImageComponent,
TranslocoPercentPipe, TranslocoPercentPipe,
ReadMoreComponent, ReadMoreComponent,
TranslocoDirective, TranslocoDirective,
PlusMediaFormatPipe, PlusMediaFormatPipe,
LoadingComponent LoadingComponent
], ],
templateUrl: './match-series-result-item.component.html', templateUrl: './match-series-result-item.component.html',
styleUrl: './match-series-result-item.component.scss', styleUrl: './match-series-result-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class MatchSeriesResultItemComponent { export class MatchSeriesResultItemComponent {

View File

@ -35,6 +35,7 @@
[count]="pageInfo.totalElements" [count]="pageInfo.totalElements"
[offset]="pageInfo.pageNumber" [offset]="pageInfo.pageNumber"
[limit]="pageInfo.size" [limit]="pageInfo.size"
[sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]"
> >
<ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false"> <ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">

View File

@ -10,7 +10,7 @@ import {debounceTime, take} from "rxjs/operators";
import {PaginatedResult} from "../../_models/pagination"; import {PaginatedResult} from "../../_models/pagination";
import {SortEvent} from "../table/_directives/sortable-header.directive"; import {SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {TranslocoModule} from "@jsverse/transloco"; import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -18,6 +18,7 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AsyncPipe} from "@angular/common"; import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ToastrService} from "ngx-toastr";
export interface DataTablePage { export interface DataTablePage {
pageNumber: number, pageNumber: number,
@ -44,6 +45,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
private readonly scrobblingService = inject(ScrobblingService); private readonly scrobblingService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService); protected readonly accountService = inject(AccountService);
@ -60,6 +62,10 @@ export class UserScrobbleHistoryComponent implements OnInit {
totalElements: 0, totalElements: 0,
totalPages: 0 totalPages: 0
} }
private currentSort: SortEvent<ScrobbleEvent> = {
column: 'lastModifiedUtc',
direction: 'desc'
};
ngOnInit() { ngOnInit() {
@ -73,26 +79,26 @@ export class UserScrobbleHistoryComponent implements OnInit {
this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
this.loadPage(); this.loadPage();
}) });
this.loadPage(this.currentSort);
} }
onPageChange(pageInfo: any) { onPageChange(pageInfo: any) {
this.pageInfo.pageNumber = pageInfo.offset; this.pageInfo.pageNumber = pageInfo.offset;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.loadPage(); this.loadPage(this.currentSort);
} }
updateSort(data: any) { updateSort(data: any) {
this.loadPage({column: data.column.prop, direction: data.newValue}); this.currentSort = {
column: data.column.prop,
direction: data.newValue
};
} }
loadPage(sortEvent?: SortEvent<ScrobbleEvent>) { loadPage(sortEvent?: SortEvent<ScrobbleEvent>) {
if (sortEvent && this.pageInfo) {
this.pageInfo.pageNumber = 1;
this.cdRef.markForCheck();
}
const page = (this.pageInfo?.pageNumber || 0) + 1; const page = (this.pageInfo?.pageNumber || 0) + 1;
const pageSize = this.pageInfo?.size || 0; const pageSize = this.pageInfo?.size || 0;
const isDescending = sortEvent?.direction === 'desc'; const isDescending = sortEvent?.direction === 'desc';
@ -102,7 +108,6 @@ export class UserScrobbleHistoryComponent implements OnInit {
this.isLoading = true; this.isLoading = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
// BUG: Table should be sorted by lastModifiedUtc by default
this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
.pipe(take(1)) .pipe(take(1))
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => { .subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
@ -122,13 +127,14 @@ export class UserScrobbleHistoryComponent implements OnInit {
case 'isProcessed': return ScrobbleEventSortField.IsProcessed; case 'isProcessed': return ScrobbleEventSortField.IsProcessed;
case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified; case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified;
case 'seriesName': return ScrobbleEventSortField.Series; case 'seriesName': return ScrobbleEventSortField.Series;
case 'scrobbleEventType': return ScrobbleEventSortField.ScrobbleEvent;
} }
return ScrobbleEventSortField.None; return ScrobbleEventSortField.None;
} }
generateScrobbleEvents() { generateScrobbleEvents() {
this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => {
this.toastr.info(translate('toasts.scrobble-gen-init'))
}); });
} }
} }

View File

@ -34,12 +34,12 @@
</ngx-datatable-column> </ngx-datatable-column>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> <ngx-datatable-column prop="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template> <ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}} {{t('created-header')}}
</ng-template> </ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template> <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.createdUtc | utcToLocalTime | defaultValue }} {{item.created | utcToLocalTime | defaultValue }}
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>
@ -57,9 +57,9 @@
{{t('edit-header')}} {{t('edit-header')}}
</ng-template> </ng-template>
<ng-template let-item="row" ngx-datatable-cell-template> <ng-template let-item="row" ngx-datatable-cell-template>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)"> <button class="btn btn-icon" (click)="fixMatch(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i> <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span> <span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span>
</button> </button>
</ng-template> </ng-template>
</ngx-datatable-column> </ngx-datatable-column>

View File

@ -20,15 +20,13 @@ import {ScrobblingService} from "../../_services/scrobbling.service";
import {ScrobbleError} from "../../_models/scrobbling/scrobble-error"; import {ScrobbleError} from "../../_models/scrobbling/scrobble-error";
import {SeriesService} from "../../_services/series.service"; import {SeriesService} from "../../_services/series.service";
import {EditSeriesModalComponent} from "../../cards/_modals/edit-series-modal/edit-series-modal.component";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {FilterPipe} from "../../_pipes/filter.pipe"; import {FilterPipe} from "../../_pipes/filter.pipe";
import {TranslocoModule} from "@jsverse/transloco"; import {TranslocoModule} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {ActionService} from "../../_services/action.service";
@Component({ @Component({
selector: 'app-manage-scrobble-errors', selector: 'app-manage-scrobble-errors',
@ -38,14 +36,20 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ManageScrobbleErrorsComponent implements OnInit { export class ManageScrobbleErrorsComponent implements OnInit {
@Output() scrobbleCount = new EventEmitter<number>(); protected readonly filter = filter;
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>; protected readonly ColumnMode = ColumnMode;
private readonly scrobbleService = inject(ScrobblingService); private readonly scrobbleService = inject(ScrobblingService);
private readonly messageHub = inject(MessageHubService); private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly seriesService = inject(SeriesService); private readonly seriesService = inject(SeriesService);
private readonly modalService = inject(NgbModal); private readonly actionService = inject(ActionService);
@Output() scrobbleCount = new EventEmitter<number>();
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>;
messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay()); messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay());
currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'}); currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'});
@ -58,8 +62,6 @@ export class ManageScrobbleErrorsComponent implements OnInit {
}); });
constructor() {}
ngOnInit() { ngOnInit() {
this.loadData(); this.loadData();
@ -108,13 +110,13 @@ export class ManageScrobbleErrorsComponent implements OnInit {
return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0; return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0;
} }
editSeries(seriesId: number) { fixMatch(seriesId: number) {
this.seriesService.getSeries(seriesId).subscribe(series => { this.seriesService.getSeries(seriesId).subscribe(series => {
const modalRef = this.modalService.open(EditSeriesModalComponent, DefaultModalOptions); this.actionService.matchSeries(series, (result) => {
modalRef.componentInstance.series = series; if (!result) return;
this.data = [...this.data.filter(s => s.seriesId !== series.id)];
this.cdRef.markForCheck();
});
}); });
} }
protected readonly filter = filter;
protected readonly ColumnMode = ColumnMode;
} }

View File

@ -7,7 +7,8 @@ import {AccountService} from "../../../_services/account.service";
import { import {
NgbAccordionBody, NgbAccordionBody,
NgbAccordionButton, NgbAccordionCollapse, NgbAccordionButton,
NgbAccordionCollapse,
NgbAccordionDirective, NgbAccordionDirective,
NgbAccordionHeader, NgbAccordionHeader,
NgbAccordionItem NgbAccordionItem
@ -32,7 +33,7 @@ export class ChangelogComponent implements OnInit {
isLoading: boolean = true; isLoading: boolean = true;
ngOnInit(): void { ngOnInit(): void {
this.serverService.getChangelog(30).subscribe(updates => { this.serverService.getChangelog(7).subscribe(updates => {
this.updates = updates; this.updates = updates;
this.isLoading = false; this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -5,37 +5,44 @@
{{t('description-part-1')}} <a [href]="WikiLink.SeriesRelationships" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{t('description-part-2')}}</a> {{t('description-part-1')}} <a [href]="WikiLink.SeriesRelationships" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{t('description-part-2')}}</a>
</p> </p>
<div class="row g-0" *ngIf="relations.length > 0"> @if (relations.length > 0) {
<label class="form-label col-md-7">{{t('target-series')}}</label> <div class="row">
<label class="form-label col-md-5">{{t('relationship')}}</label> <label class="form-label col-md-7">{{t('target-series')}}</label>
</div> <label class="form-label col-md-5">{{t('relationship')}}</label>
</div>
}
<form> <form>
<div class="row g-0" *ngFor="let relation of relations; let idx = index; let isLast = last;"> @for(relation of relations; let idx = $index; track idx) {
<div class="col-sm-12 col-md-12 col-lg-7 mb-3"> <div class="row">
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead"> <div class="col-sm-12 col-md-12 col-lg-7 mb-3">
<ng-template #badgeItem let-item let-position="idx"> <app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead">
{{item.name}} ({{libraryNames[item.libraryId]}}) <ng-template #badgeItem let-item let-position="idx">
</ng-template> {{item.name}} ({{libraryNames[item.libraryId]}})
<ng-template #optionItem let-item let-position="idx" let-value="value"> </ng-template>
@if (item.name.toLowerCase().trim().indexOf(value.toLowerCase().trim()) >= 0) { <ng-template #optionItem let-item let-position="idx" let-value="value">
{{item.name}} @if (item.name.toLowerCase().trim().indexOf(value.toLowerCase().trim()) >= 0) {
} @else { {{item.name}}
{{item.localizedName}} } @else {
{{item.localizedName}}
}
({{libraryNames[item.libraryId]}})
</ng-template>
</app-typeahead>
</div>
<div class="col-sm-12 col-md-10 col-lg-3 mb-3">
<select class="form-select" [formControl]="relation.formControl">
<option [value]="RelationKind.Parent" disabled>{{t('parent')}}</option>
@for(opt of relationOptions; track opt) {
<option [value]="opt.value">{{opt.value | relationship }}</option>
} }
({{libraryNames[item.libraryId]}}) </select>
</ng-template> </div>
</app-typeahead> <button class="col-sm-auto col-md-2 mb-3 btn btn-outline-secondary" (click)="removeRelation(idx)">
<i class="fa fa-trash" aria-hidden="true"></i><span class="visually-hidden">{{t('remove')}}</span></button>
</div> </div>
<div class="col-sm-12 col-md-10 col-lg-3 mb-3"> }
<select class="form-select" [formControl]="relation.formControl">
<option [value]="RelationKind.Parent" disabled>{{t('parent')}}</option>
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.value | relationship }}</option>
</select>
</div>
<button class="col-sm-auto col-md-2 mb-3 btn btn-outline-secondary" (click)="removeRelation(idx)">
<i class="fa fa-trash"></i><span class="visually-hidden">{{t('remove')}}</span></button>
</div>
</form> </form>
<div class="row g-0 mt-3 mb-3"> <div class="row g-0 mt-3 mb-3">

View File

@ -1,7 +1,8 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
@ -9,19 +10,18 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {FormControl, ReactiveFormsModule} from '@angular/forms';
import { map, Observable, of, firstValueFrom, ReplaySubject } from 'rxjs'; import {firstValueFrom, map, Observable, of, ReplaySubject} from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service'; import {UtilityService} from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings'; import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
import { SearchResult } from 'src/app/_models/search/search-result'; import {SearchResult} from 'src/app/_models/search/search-result';
import { Series } from 'src/app/_models/series'; import {Series} from 'src/app/_models/series';
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind'; import {RelationKind, RelationKinds} from 'src/app/_models/series-detail/relation-kind';
import { ImageService } from 'src/app/_services/image.service'; import {ImageService} from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service'; import {LibraryService} from 'src/app/_services/library.service';
import { SearchService } from 'src/app/_services/search.service'; import {SearchService} from 'src/app/_services/search.service';
import { SeriesService } from 'src/app/_services/series.service'; import {SeriesService} from 'src/app/_services/series.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {CommonModule} from "@angular/common";
import {TranslocoModule} from "@jsverse/transloco"; import {TranslocoModule} from "@jsverse/transloco";
import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {RelationshipPipe} from "../../_pipes/relationship.pipe";
import {WikiLink} from "../../_models/wiki"; import {WikiLink} from "../../_models/wiki";
@ -36,7 +36,6 @@ interface RelationControl {
selector: 'app-edit-series-relation', selector: 'app-edit-series-relation',
imports: [ imports: [
TypeaheadComponent, TypeaheadComponent,
CommonModule,
ReactiveFormsModule, ReactiveFormsModule,
TranslocoModule, TranslocoModule,
RelationshipPipe, RelationshipPipe,
@ -113,7 +112,8 @@ export class EditSeriesRelationComponent implements OnInit {
} }
async addNewRelation() { async addNewRelation() {
this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []),
typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))});
this.cdRef.markForCheck(); this.cdRef.markForCheck();
// Focus on the new typeahead // Focus on the new typeahead

View File

@ -66,7 +66,7 @@
<div class="card-title-container"> <div class="card-title-container">
<app-series-format [format]="series.format"></app-series-format> <app-series-format [format]="series.format"></app-series-format>
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}"> <span class="card-title" [ngbTooltip]="series.name" placement="top" id="{{series.id}}">
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}"> <a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}">
{{series.name}} {{series.name}}
</a> </a>

View File

@ -1,9 +1,10 @@
import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common'; import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
ElementRef, ElementRef,
EventEmitter, EventEmitter,
inject, inject,
@ -14,15 +15,16 @@ import {
OnInit, OnInit,
Output, Output,
Renderer2, Renderer2,
SimpleChanges, ViewChild SimpleChanges,
ViewChild
} from '@angular/core'; } from '@angular/core';
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs';
import { debounceTime } from 'rxjs/operators'; import {debounceTime} from 'rxjs/operators';
import { ScrollService } from 'src/app/_services/scroll.service'; import {ScrollService} from 'src/app/_services/scroll.service';
import { ReaderService } from '../../../_services/reader.service'; import {ReaderService} from '../../../_services/reader.service';
import { PAGING_DIRECTION } from '../../_models/reader-enums'; import {PAGING_DIRECTION} from '../../_models/reader-enums';
import { WebtoonImage } from '../../_models/webtoon-image'; import {WebtoonImage} from '../../_models/webtoon-image';
import { MangaReaderService } from '../../_service/manga-reader.service'; import {MangaReaderService} from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {InfiniteScrollModule} from "ngx-infinite-scroll"; import {InfiniteScrollModule} from "ngx-infinite-scroll";
@ -352,17 +354,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
if (!this.isScrolling) { // if (!this.isScrolling) {
// Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this // // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
// to mark the current page and separate the prefetching code. // // to mark the current page and separate the prefetching code.
const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]')) // const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
.filter(entry => this.shouldElementCountAsCurrentPage(entry)); // .filter(entry => this.shouldElementCountAsCurrentPage(entry));
//
if (midlineImages.length > 0) { // if (midlineImages.length > 0) {
this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10)); // this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
} // }
} // }
//
// Check if we hit the last page // Check if we hit the last page
this.checkIfShouldTriggerContinuousReader(); this.checkIfShouldTriggerContinuousReader();
} }
@ -426,8 +428,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
this.checkIfShouldTriggerContinuousReader() this.checkIfShouldTriggerContinuousReader()
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
// This if statement will fire once we scroll into the spacer at all // This if statement will fire once we scroll into the spacer at all
this.loadNextChapter.emit(); this.moveToNextChapter();
this.cdRef.markForCheck();
} }
} else { } else {
// < 5 because debug mode and FF (mobile) can report non 0, despite being at 0 // < 5 because debug mode and FF (mobile) can report non 0, despite being at 0
@ -442,7 +443,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body; const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body;
requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader)); requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader));
} else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) { } else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) {
// If already at top, then we moving on // If already at top, then we are moving on
this.loadPrevChapter.emit(); this.loadPrevChapter.emit();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
@ -597,7 +598,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
handleBottomIntersection(entries: IntersectionObserverEntry[]) { handleBottomIntersection(entries: IntersectionObserverEntry[]) {
if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) { if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) {
this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting); this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting);
this.loadNextChapter.emit(); this.moveToNextChapter();
} }
} }
@ -617,6 +618,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
}); });
} }
/**
* Move to the next chapter and set the page
*/
moveToNextChapter() {
this.setPageNum(this.totalPages);
this.loadNextChapter.emit();
}
/** /**
* Set the page number, invoke prefetching and optionally scroll to the new page. * Set the page number, invoke prefetching and optionally scroll to the new page.
* @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter. * @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter.

View File

@ -621,11 +621,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
// fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => {
// if (event.detail > 1) return;
// this.toggleMenu();
// });
fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => { fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0; this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0; this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
@ -640,6 +635,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.navService.showSideNav(); this.navService.showSideNav();
this.showBookmarkEffectEvent.complete(); this.showBookmarkEffectEvent.complete();
if (this.goToPageEvent !== undefined) this.goToPageEvent.complete(); if (this.goToPageEvent !== undefined) this.goToPageEvent.complete();
this.readerService.disableWakeLock(); this.readerService.disableWakeLock();
} }
@ -784,6 +780,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
switchToWebtoonReaderIfPagesLikelyWebtoon() { switchToWebtoonReaderIfPagesLikelyWebtoon() {
if (this.readerMode === ReaderMode.Webtoon) return; if (this.readerMode === ReaderMode.Webtoon) return;
if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return;
if (this.mangaReaderService.shouldBeWebtoonMode()) { if (this.mangaReaderService.shouldBeWebtoonMode()) {
this.readerMode = ReaderMode.Webtoon; this.readerMode = ReaderMode.Webtoon;

View File

@ -1,11 +1,11 @@
import { ElementRef, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; import {ElementRef, Injectable, Renderer2, RendererFactory2} from '@angular/core';
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
import { ScalingOption } from 'src/app/_models/preferences/scaling-option'; import {ScalingOption} from 'src/app/_models/preferences/scaling-option';
import { ReaderService } from 'src/app/_services/reader.service'; import {ReaderService} from 'src/app/_services/reader.service';
import { ChapterInfo } from '../_models/chapter-info'; import {ChapterInfo} from '../_models/chapter-info';
import { DimensionMap } from '../_models/file-dimension'; import {DimensionMap} from '../_models/file-dimension';
import { FITTING_OPTION } from '../_models/reader-enums'; import {FITTING_OPTION} from '../_models/reader-enums';
import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info'; import {BookmarkInfo} from 'src/app/_models/manga-reader/bookmark-info';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -113,10 +113,12 @@ export class MangaReaderService {
return !(this.isNoSplit(pageSplitOption) || !needsSplitting) return !(this.isNoSplit(pageSplitOption) || !needsSplitting)
} }
/**
* Some pages aren't cover images but might need fit split renderings
* @param pageSplitOption
*/
shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) { shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) {
// Some pages aren't cover images but might need fit split renderings return parseInt(pageSplitOption + '', 10) === PageSplitOption.FitSplit;
if (parseInt(pageSplitOption + '', 10) !== PageSplitOption.FitSplit) return false;
return true;
} }
@ -156,27 +158,97 @@ export class MangaReaderService {
shouldBeWebtoonMode() { shouldBeWebtoonMode() {
const pages = Object.values(this.pageDimensions); const pages = Object.values(this.pageDimensions);
// Require a minimum number of pages for reliable detection
if (pages.length < 3) {
return false;
}
// Get statistical properties across all pages
const aspectRatios = pages.map(info => info.height / info.width);
const avgAspectRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / pages.length;
const stdDevAspectRatio = Math.sqrt(
aspectRatios.reduce((sum, ratio) => sum + Math.pow(ratio - avgAspectRatio, 2), 0) / pages.length
);
// Consider page dimensions consistency
const widths = pages.map(info => info.width);
const heights = pages.map(info => info.height);
const avgWidth = widths.reduce((sum, w) => sum + w, 0) / pages.length;
const avgHeight = heights.reduce((sum, h) => sum + h, 0) / pages.length;
// Calculate variation coefficients for width and height
const widthVariation = Math.sqrt(
widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / pages.length
) / avgWidth;
// Calculate individual scores for each page
let webtoonScore = 0; let webtoonScore = 0;
let strongIndicatorCount = 0;
pages.forEach(info => { pages.forEach(info => {
const aspectRatio = info.height / info.width; const aspectRatio = info.height / info.width;
let score = 0; let score = 0;
// Strong webtoon indicator: If aspect ratio is at least 2:1 // Strong webtoon indicator: If aspect ratio is at least 2:1
if (aspectRatio >= 2) { if (aspectRatio >= 2.2) {
score += 1; score += 1;
strongIndicatorCount++;
} else if (aspectRatio >= 1.8 && aspectRatio < 2.2) {
// Moderate indicator
score += 0.5;
} else if (aspectRatio >= 1.5 && aspectRatio < 1.8) {
// Weak indicator - many regular manga/comics have ratios in this range
score += 0.2;
} }
// Boost score if width is small (≤ 800px, common in webtoons) // Penalize pages that are too square-like (common in traditional comics)
if (aspectRatio < 1.2) {
score -= 0.5;
}
// Consider width but with less weight than before
if (info.width <= 750) { if (info.width <= 750) {
score += 0.5; // Adjust weight as needed score += 0.2;
}
// Consider absolute height (long strips tend to be very tall)
if (info.height > 2000) {
score += 0.5;
} else if (info.height > 1500) {
score += 0.3;
}
// Consider absolute page area - webtoons tend to have larger total area
const area = info.width * info.height;
if (area > 1500000) { // e.g., 1000×1500 or larger
score += 0.3;
} }
webtoonScore += score; webtoonScore += score;
}); });
const averageScore = webtoonScore / pages.length;
// If at least 50% of the pages fit the webtoon criteria, switch to Webtoon mode. // Multiple criteria for more robust detection
return webtoonScore / pages.length >= 0.5; // Check for typical manga/comic dimensions that should NOT be webtoon mode
const isMangaLikeSize = avgHeight < 1200 && avgAspectRatio < 1.7 && avgWidth < 700;
// Main detection criteria
return (
// Primary criterion: average score threshold (increased)
averageScore >= 0.7 &&
// Not resembling typical manga/comic dimensions
!isMangaLikeSize &&
// Secondary criteria (any one can satisfy)
(
// Most pages should have high aspect ratio
(strongIndicatorCount / pages.length >= 0.4) ||
// Average aspect ratio is high enough (increased threshold)
(avgAspectRatio >= 2.0) ||
// Pages have consistent width AND very high aspect ratio
(widthVariation < 0.15 && avgAspectRatio > 1.8)
)
);
} }

View File

@ -1,59 +1,67 @@
<ng-container *transloco="let t; read: 'draggable-ordered-list'"> <ng-container *transloco="let t; read: 'draggable-ordered-list'">
@if (items.length > virtualizeAfter) {
<div class="example-list list-group-flush">
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
<ng-container *ngIf="items.length > virtualizeAfter; else dragList"> <div class="d-flex list-container">
<div class="example-list list-group-flush"> <ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: true }"></ng-container>
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll"> <ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
<div class="d-flex list-container"> <ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: true }"></ng-container>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
</div>
</div>
</virtual-scroller>
</div>
</ng-container>
<ng-template #dragList>
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
<div class="example-box" *ngFor="let item of items; index as i;" cdkDrag
[cdkDragData]="item" cdkDragBoundary=".example-list"
[cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent">
<div class="d-flex list-container">
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
</div>
</div> </div>
</div> </div>
</ng-template> </virtual-scroller>
</div>
} @else {
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
@for(item of items; track item; let i = $index) {
<div class="example-box" cdkDrag
[cdkDragData]="item" cdkDragBoundary=".example-list"
[cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent">
<div class="d-flex list-container">
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
@if (showRemoveButton) {
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
}
</div>
</div>
}
</div>
}
<ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized"> <ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized">
<div class="me-3 align-middle"> <div class="me-3 align-middle">
<div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}" *ngIf="accessibilityMode || bulkMode"> @if (accessibilityMode || bulkMode) {
<ng-container *ngIf="accessibilityMode"> <div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}">
<label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label> @if (accessibilityMode) {
<input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0" <label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
[max]="items.length - 1" [value]="item.order" <input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0"
(focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions"> [max]="items.length - 1" [value]="item.order"
</ng-container> (focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions">
<ng-container *ngIf="bulkMode"> }
<label for="select-{{idx}}" class="form-label visually-hidden">{{t('bulk-select-label')}}</label>
<input id="select-{{idx}}" class="form-check-input mt-0" type="checkbox" (change)="selectItem($event, idx)" @if (bulkMode) {
[ngModel]="bulkSelectionService.isCardSelected('sideNavStream', idx)" [ngModelOptions]="{standalone: true}"> <label for="select-{{idx}}" class="form-label visually-hidden">{{t('bulk-select-label')}}</label>
</ng-container> <input id="select-{{idx}}" class="form-check-input mt-0" type="checkbox" (change)="selectItem($event, idx)"
[ngModel]="bulkSelectionService.isCardSelected('sideNavStream', idx)" [ngModelOptions]="{standalone: true}">
}
</div> </div>
<i *ngIf="!isVirtualized && !(accessibilityMode || bulkMode) && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i> }
@if (!isVirtualized && !(accessibilityMode || bulkMode) && !disabled) {
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
}
</div> </div>
</ng-template> </ng-template>
<ng-template #removeBtn let-item let-idx> <ng-template #removeBtn let-item let-idx>
<button class="btn btn-icon float-end" (click)="removeItem(item, idx)" *ngIf="showRemoveButton" [disabled]="disabled"> <button class="btn btn-icon float-end" (click)="removeItem(item, idx)" [disabled]="disableRemove">
<i class="fa fa-times" aria-hidden="true"></i> <i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span> <span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span>
</button> </button>

View File

@ -13,7 +13,7 @@ import {
TrackByFunction TrackByFunction
} from '@angular/core'; } from '@angular/core';
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
import {NgClass, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; import {NgClass, NgFor, NgTemplateOutlet} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {BulkSelectionService} from "../../../cards/bulk-selection.service"; import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
@ -36,11 +36,15 @@ export interface ItemRemoveEvent {
templateUrl: './draggable-ordered-list.component.html', templateUrl: './draggable-ordered-list.component.html',
styleUrls: ['./draggable-ordered-list.component.scss'], styleUrls: ['./draggable-ordered-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, imports: [VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag,
CdkDragHandle, TranslocoDirective, NgClass, FormsModule] CdkDragHandle, TranslocoDirective, NgClass, FormsModule]
}) })
export class DraggableOrderedListComponent { export class DraggableOrderedListComponent {
protected readonly bulkSelectionService = inject(BulkSelectionService);
private readonly destroyRef = inject(DestroyRef);
/** /**
* After this many elements, drag and drop is disabled and we use a virtualized list instead * After this many elements, drag and drop is disabled and we use a virtualized list instead
*/ */
@ -59,6 +63,10 @@ export class DraggableOrderedListComponent {
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index. * Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
*/ */
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
/**
* Disables remove button
*/
@Input() disableRemove: boolean = false;
/** /**
* When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle * When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle
*/ */
@ -71,8 +79,6 @@ export class DraggableOrderedListComponent {
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>(); @Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>; @ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly destroyRef = inject(DestroyRef);
get BufferAmount() { get BufferAmount() {
return Math.min(this.items.length / 20, 20); return Math.min(this.items.length / 20, 20);

View File

@ -1,168 +1,266 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'reading-list-detail'"> <ng-container *transloco="let t; read: 'reading-list-detail'">
<app-side-nav-companion-bar [hasExtras]="readingList !== undefined" [extraDrawer]="extrasDrawer"> <form [formGroup]="formGroup">
<h4 title> @if (readingList) {
{{readingList?.title}} <div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock>
@if (readingList?.promoted) { <div class="row mb-0 mb-xl-3 info-container">
<span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span> <div class="image-container series col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative">
} <app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)" (click)="read()"></app-image>
@if (actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title"></app-card-actionables>
}
</h4>
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h5>
<ng-template #extrasDrawer let-offcanvas>
@if (readingList) {
<div>
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body">
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<button class="btn btn-danger" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin">
<span>
<i class="fa fa-check"></i>
</span>
<span class="read-btn--text">&nbsp;{{t('remove-read')}}</span>
</button>
@if (!(readingList.promoted && !this.isAdmin)) {
<div class="col-auto ms-2 mt-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [disabled]="this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
}
</div>
</div>
</div>
</div> </div>
}
</ng-template>
</app-side-nav-companion-bar>
@if (readingList) { <div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
<div class="container-fluid mt-2"> <h4 class="title mb-2">
<span>{{readingList.title}}
@if (readingList.promoted) {
(<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>)
}
<div class="row mb-2"> @if( isLoading) {
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block"> <div class="spinner-border spinner-border-sm text-primary" role="status">
<app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image> <span class="visually-hidden">loading...</span>
</div>
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<div class="row g-0 mb-3">
<div class="col-auto me-2">
<!-- Action row-->
<div class="btn-group me-3">
<button type="button" class="btn btn-primary" (click)="continue()">
<span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read()">
<span>
<i class="fa fa-book" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span>
</span>
</button>
<button ngbDropdownItem (click)="continue(true)">
<span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span>
</span>
</button>
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
@if (readingList.startingYear !== 0) {
<div class="row g-0 mt-2">
<h4 class="reading-list-years">
@if (readingList.startingMonth > 0) {
{{(readingList.startingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.startingMonth > 0 && readingList.startingYear > 0) {
,
}
@if (readingList.startingYear > 0) {
{{readingList.startingYear}}
}
@if (readingList.endingYear > 0) {
@if (readingList.endingMonth > 0) {
{{(readingList.endingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.endingMonth > 0 && readingList.endingYear > 0) {
,
}
@if (readingList.endingYear > 0) {
{{readingList.endingYear}}
}
}
</h4>
</div>
}
<!-- Summary row-->
<div class="row g-0 my-2">
<app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div>
@if (characters$ | async; as characters) {
@if (characters && characters.length > 0) {
<div class="row mb-2">
<div class="row">
<h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="goToCharacter(item)">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>
</div> </div>
} }
} </span>
</h4>
<!-- <app-metadata-detail-row [entity]="seriesMetadata"-->
<!-- [readingTimeLeft]="readingTimeLeft"-->
<!-- [ageRating]="seriesMetadata.ageRating"-->
<!-- [hasReadingProgress]="hasReadingProgress"-->
<!-- [readingTimeEntity]="series"-->
<!-- [libraryType]="libraryType"-->
<!-- [mangaFormat]="series.format">-->
<!-- </app-metadata-detail-row>-->
<div class="mt-3 mb-3">
<div class="row g-0" style="align-items: center;">
<div class="col-auto me-2">
<!-- Action row-->
<div class="btn-group me-3">
<button type="button" class="btn btn-outline-primary" (click)="continue()">
<span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span>
</span>
</button>
<div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read()">
<span>
<i class="fa fa-book" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span>
</span>
</button>
<button ngbDropdownItem (click)="continue(true)">
<span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span>
</span>
</button>
<button ngbDropdownItem (click)="read(true)">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span>
</span>
</button>
</div>
</div>
</div>
</div>
@if (isOwnedReadingList) {
<div class="col-auto ms-2">
<button class="btn btn-actions" (click)="editReadingList(readingList)" [ngbTooltip]="t('edit-alt')">
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
</button>
</div>
}
<div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
</div>
</div>
<div class="col-auto ms-2 d-none d-md-block btn-actions">
<button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')">
<i class="fa-solid fa-list-ol" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="mt-2 mb-3">
<app-read-more [text]="readingList.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more>
</div>
<div class="mt-2 upper-details">
<div class="row g-0">
<div class="col-6 pe-5">
<span class="fw-bold">{{t('date-range-title')}}</span>
<div>
@if (readingList.startingYear !== 0) {
@if (readingList.startingMonth > 0) {
{{(readingList.startingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.startingMonth > 0 && readingList.startingYear > 0) {
,
}
@if (readingList.startingYear > 0) {
{{readingList.startingYear}}
}
@if (readingList.endingYear > 0) {
@if (readingList.endingMonth > 0) {
{{(readingList.endingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.endingMonth > 0 && readingList.endingYear > 0) {
,
}
@if (readingList.endingYear > 0) {
{{readingList.endingYear}}
}
}
} @else {
{{null | defaultValue}}
}
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('items-title')}}</span>
<div>
{{t('item-count', {num: items.length | number})}}
</div>
</div>
</div>
</div>
<div class="mt-3 mb-2 upper-details">
<div class="row g-0">
<div class="col-6 pe-5">
<span class="fw-bold">{{t('writers-title')}}</span>
<div class="row mb-2">
<div class="row">
<app-badge-expander [items]="castInfo.writers"
[itemsTillExpander]="3"
[allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('cover-artists-title')}}</span>
<div class="row mb-2">
<div class="row">
<app-badge-expander [items]="castInfo.coverArtists"
[itemsTillExpander]="3"
[allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
</div>
</div>
</div>
<div class="mt-3 mb-2 upper-details">
<!-- Edit Row -->
<div class="row g-0">
@if (formGroup.get('edit')?.value) {
@if (!readingList.promoted && this.isOwnedReadingList) {
<div class="col-auto">
<button class="btn btn-sm btn-danger" (click)="removeRead()">
<i class="fa fa-check me-1" aria-hidden="true"></i>
{{t('remove-read')}}
</button>
</div>
}
<div class="col-auto ms-2">
<div class="form-check form-switch form-check-inline mt-1">
<input class="form-check-input" type="checkbox" id="accessibility-mode" formControlName="accessibilityMode">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
}
</div>
</div>
</div> </div>
</div> </div>
<div class="row mb-1 scroll-container" #scrollingBlock> <div class="carousel-tabs-container mb-2">
@if (items.length === 0 && !isLoading) { <ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<div class="mx-auto" style="width: 200px;">
{{t('no-data')}}
</div>
} @else if(isLoading) {
<app-loading [loading]="isLoading"></app-loading>
}
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode" @if (showStorylineTab) {
[showRemoveButton]="false"> <li [ngbNavItem]="TabID.Storyline">
<ng-template #draggableItem let-item let-position="idx"> <a ngbNavLink>{{t(TabID.Storyline)}}</a>
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item" [position]="position" [libraryTypes]="libraryTypes" <ng-template ngbNavContent>
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)"></app-reading-list-item> @defer (when activeTabId === TabID.Storyline; prefetch on idle) {
</ng-template> <div class="row mb-1 scroll-container" #scrollingBlock>
</app-draggable-ordered-list> @if (items.length === 0 && !isLoading) {
<div class="mx-auto" style="width: 200px;">
{{t('no-data')}}
</div>
} @else if(isLoading) {
<app-loading [loading]="isLoading"></app-loading>
}
@if(formGroup.get('edit')?.value && (items.length > 100 || utilityService.getActiveBreakpoint() < Breakpoint.Tablet)) {
<div class="alert alert-secondary mt-2" role="alert">
{{t('dnd-warning')}}
</div>
}
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[disabled]="!(formGroup.get('edit')?.value || false)" [showRemoveButton]="formGroup.get('edit')?.value || false">
<ng-template #draggableItem let-item let-position="idx">
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item" [position]="position" [libraryTypes]="libraryTypes"
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)" [showRead]="!(formGroup.get('edit')?.value || false)"></app-reading-list-item>
</ng-template>
</app-draggable-ordered-list>
</div>
}
</ng-template>
</li>
}
@if (rlInfo && castInfo) {
<li [ngbNavItem]="TabID.Details" id="details-tab">
<a ngbNavLink>{{t(TabID.Details)}}</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Details; prefetch on idle) {
<app-details-tab [metadata]="castInfo"
[readingTime]="rlInfo"
[ageRating]="readingList.ageRating"/>
}
</ng-template>
</li>
}
</ul>
</div> </div>
<div [ngbNavOutlet]="nav" style="min-height: 300px"></div>
</div> </div>
} }
</form>
</ng-container> </ng-container>
</div> </div>

View File

@ -1,3 +1,6 @@
@use '../../../../series-detail-common';
.main-container { .main-container {
margin-top: 10px; margin-top: 10px;
padding: 0 0 0 10px; padding: 0 0 0 10px;

View File

@ -1,12 +1,23 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {
import {ActivatedRoute, Router} from '@angular/router'; ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
ElementRef,
Inject,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {AsyncPipe, DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators'; import {take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service'; import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library'; import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format'; import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list'; import {ReadingList, ReadingListInfo, ReadingListItem} from 'src/app/_models/reading-list';
import {AccountService} from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service'; import {ActionService} from 'src/app/_services/action.service';
@ -16,57 +27,84 @@ import {
DraggableOrderedListComponent, DraggableOrderedListComponent,
IndexUpdateEvent IndexUpdateEvent
} from '../draggable-ordered-list/draggable-ordered-list.component'; } from '../draggable-ordered-list/draggable-ordered-list.component';
import {forkJoin, Observable} from 'rxjs'; import {forkJoin, startWith, tap} from 'rxjs';
import {ReaderService} from 'src/app/_services/reader.service'; import {ReaderService} from 'src/app/_services/reader.service';
import {LibraryService} from 'src/app/_services/library.service'; import {LibraryService} from 'src/app/_services/library.service';
import {Person} from 'src/app/_models/metadata/person';
import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component'; import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component';
import {LoadingComponent} from '../../../shared/loading/loading.component'; import {LoadingComponent} from '../../../shared/loading/loading.component';
import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component'; import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component';
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import {ImageComponent} from '../../../shared/image/image.component';
import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common';
import { import {
SideNavCompanionBarComponent NgbDropdown,
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle,
NgbNav,
NgbNavChangeEvent,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ImageComponent} from '../../../shared/image/image.component';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {Title} from "@angular/platform-browser"; import {Title} from "@angular/platform-browser";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component";
import {IHasCast} from "../../../_models/common/i-has-cast";
enum TabID {
Storyline = 'storyline-tab',
Volumes = 'volume-tab',
Details = 'details-tab',
}
@Component({ @Component({
selector: 'app-reading-list-detail', selector: 'app-reading-list-detail',
templateUrl: './reading-list-detail.component.html', templateUrl: './reading-list-detail.component.html',
styleUrls: ['./reading-list-detail.component.scss'], styleUrls: ['./reading-list-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, NgbDropdown, imports: [CardActionablesComponent, ImageComponent, NgbDropdown,
NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent,
LoadingComponent, DraggableOrderedListComponent, LoadingComponent, DraggableOrderedListComponent,
ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective] ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule,
NgbNav, NgbNavContent, NgbNavLink, NgbTooltip,
RouterLink, VirtualScrollerModule, NgStyle, NgbNavOutlet, NgbNavItem, PromotedIconComponent, DefaultValuePipe, DetailsTabComponent]
}) })
export class ReadingListDetailComponent implements OnInit { export class ReadingListDetailComponent implements OnInit {
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly encodeURIComponent = encodeURIComponent;
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private readingListService = inject(ReadingListService); private readingListService = inject(ReadingListService);
private actionService = inject(ActionService); private actionService = inject(ActionService);
private actionFactoryService = inject(ActionFactoryService); private actionFactoryService = inject(ActionFactoryService);
public utilityService = inject(UtilityService); protected utilityService = inject(UtilityService);
public imageService = inject(ImageService); protected imageService = inject(ImageService);
private accountService = inject(AccountService); private accountService = inject(AccountService);
private toastr = inject(ToastrService); private toastr = inject(ToastrService);
private confirmService = inject(ConfirmService); private confirmService = inject(ConfirmService);
private libraryService = inject(LibraryService); private libraryService = inject(LibraryService);
private readerService = inject(ReaderService); private readerService = inject(ReaderService);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private filterUtilityService = inject(FilterUtilitiesService);
private titleService = inject(Title); private titleService = inject(Title);
private location = inject(Location);
private destroyRef = inject(DestroyRef);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
items: Array<ReadingListItem> = []; items: Array<ReadingListItem> = [];
listId!: number; listId!: number;
@ -75,12 +113,62 @@ export class ReadingListDetailComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
isLoading: boolean = false; isLoading: boolean = false;
accessibilityMode: boolean = false; accessibilityMode: boolean = false;
editMode: boolean = false;
readingListSummary: string = ''; readingListSummary: string = '';
libraryTypes: {[key: number]: LibraryType} = {}; libraryTypes: {[key: number]: LibraryType} = {};
characters$!: Observable<Person[]>; activeTabId = TabID.Storyline;
showStorylineTab = true;
isOwnedReadingList: boolean = false;
rlInfo: ReadingListInfo | null = null;
castInfo: IHasCast = {
characterLocked: false,
characters: [],
coloristLocked: false,
colorists: [],
coverArtistLocked: false,
coverArtists: [],
editorLocked: false,
editors: [],
imprintLocked: false,
imprints: [],
inkerLocked: false,
inkers: [],
languageLocked: false,
lettererLocked: false,
letterers: [],
locationLocked: false,
locations: [],
pencillerLocked: false,
pencillers: [],
publisherLocked: false,
publishers: [],
teamLocked: false,
teams: [],
translatorLocked: false,
translators: [],
writerLocked: false,
writers: []
};
formGroup = new FormGroup({
'edit': new FormControl(false, []),
'accessibilityMode': new FormControl(false, []),
});
get ScrollingBlockHeight() {
if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)';
const navbar = this.document.querySelector('.navbar') as HTMLElement;
if (navbar === null) return 'calc(var(--vh)*100)';
const companionHeight = this.companionBar?.nativeElement.offsetHeight || 0;
const navbarHeight = navbar.offsetHeight;
const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
}
constructor(@Inject(DOCUMENT) private document: Document) {}
ngOnInit(): void { ngOnInit(): void {
@ -92,9 +180,45 @@ export class ReadingListDetailComponent implements OnInit {
} }
this.listId = parseInt(listId, 10); this.listId = parseInt(listId, 10);
this.characters$ = this.readingListService.getCharacters(this.listId);
this.readingListService.getAllPeople(this.listId).subscribe(allPeople => {
this.castInfo = allPeople;
this.cdRef.markForCheck();
});
this.readingListService.getReadingListInfo(this.listId).subscribe(info => {
this.rlInfo = info;
this.cdRef.markForCheck();
});
this.formGroup.get('edit')!.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef),
startWith(false),
tap(mode => {
this.editMode = (mode || false);
this.cdRef.markForCheck();
})
).subscribe();
this.formGroup.get('accessibilityMode')!.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef),
startWith(this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet),
tap(mode => {
this.accessibilityMode = (mode || this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet);
this.cdRef.markForCheck();
})
).subscribe();
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
this.formGroup.get('accessibilityMode')?.disable();
}
this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet; this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet;
this.editMode = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
forkJoin([ forkJoin([
@ -104,7 +228,7 @@ export class ReadingListDetailComponent implements OnInit {
const libraries = results[0]; const libraries = results[0];
const readingList = results[1]; const readingList = results[1];
this.titleService.setTitle('Kavita - ' + readingList.title);
libraries.forEach(lib => { libraries.forEach(lib => {
this.libraryTypes[lib.id] = lib.type; this.libraryTypes[lib.id] = lib.type;
@ -116,8 +240,10 @@ export class ReadingListDetailComponent implements OnInit {
this.router.navigateByUrl('library'); this.router.navigateByUrl('library');
return; return;
} }
this.readingList = readingList; this.readingList = readingList;
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>'); this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
this.titleService.setTitle('Kavita - ' + readingList.title);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -127,10 +253,12 @@ export class ReadingListDetailComponent implements OnInit {
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
this.isOwnedReadingList = this.actions.filter(a => a.action === Action.Edit).length > 0;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
}); });
}); });
this.getListItems(); this.getListItems();
} }
@ -163,14 +291,7 @@ export class ReadingListDetailComponent implements OnInit {
await this.deleteList(readingList); await this.deleteList(readingList);
break; break;
case Action.Edit: case Action.Edit:
this.actionService.editReadingList(readingList, (readingList: ReadingList) => { this.editReadingList(readingList);
// Reload information around list
this.readingListService.getReadingList(this.listId).subscribe(rl => {
this.readingList = rl;
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
this.cdRef.markForCheck();
});
});
break; break;
case Action.Promote: case Action.Promote:
this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => { this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => {
@ -191,6 +312,17 @@ export class ReadingListDetailComponent implements OnInit {
} }
} }
editReadingList(readingList: ReadingList) {
this.actionService.editReadingList(readingList, (readingList: ReadingList) => {
// Reload information around list
this.readingListService.getReadingList(this.listId).subscribe(rl => {
this.readingList = rl!;
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
this.cdRef.markForCheck();
});
});
}
async deleteList(readingList: ReadingList) { async deleteList(readingList: ReadingList) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
@ -260,7 +392,32 @@ export class ReadingListDetailComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
goToCharacter(character: Person) {
this.filterUtilityService.applyFilter(['all-series'], FilterField.Characters, FilterComparison.Contains, character.id + '').subscribe(); toggleReorder() {
this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value);
this.cdRef.markForCheck();
}
onNavChange(event: NgbNavChangeEvent) {
this.updateUrl(event.nextId);
this.cdRef.markForCheck();
}
private updateUrl(activeTab: TabID) {
const tokens = this.location.path().split('#');
const newUrl = `${tokens[0]}#${activeTab}`;
this.location.replaceState(newUrl)
}
switchTabsToDetail() {
this.activeTabId = TabID.Details;
this.cdRef.markForCheck();
setTimeout(() => {
const tabElem = this.document.querySelector('#details-tab');
if (tabElem) {
(tabElem as HTMLLIElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}, 10);
} }
} }

View File

@ -17,18 +17,23 @@
<h5 class="mb-1 pb-0" id="item.id--{{position}}"> <h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.title}} {{item.title}}
<div class="actions float-end"> <div class="actions float-end">
<button class="btn btn-danger" (click)="remove.emit(item)"> @if (showRemove) {
<span> <button class="btn btn-danger" (click)="remove.emit(item)">
<i class="fa fa-trash me-1" aria-hidden="true"></i> <span>
</span> <i class="fa fa-trash me-1" aria-hidden="true"></i>
<span class="d-none d-md-inline-block">{{t('remove')}}</span> </span>
</button> <span class="d-none d-md-inline-block">{{t('remove')}}</span>
<button class="btn btn-primary ms-2" (click)="readChapter(item)"> </button>
<span> }
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span> @if (showRead) {
<span class="d-none d-md-inline-block">{{t('read')}}</span> <button class="btn btn-outline-primary ms-2" (click)="readChapter(item)">
</button> <span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-md-inline-block">{{t('read')}}</span>
</button>
}
</div> </div>
</h5> </h5>

View File

@ -24,6 +24,8 @@ export class ReadingListItemComponent {
@Input({required: true}) item!: ReadingListItem; @Input({required: true}) item!: ReadingListItem;
@Input() position: number = 0; @Input() position: number = 0;
@Input() showRemove: boolean = false;
@Input() showRead: boolean = true;
@Input() libraryTypes: {[key: number]: LibraryType} = {}; @Input() libraryTypes: {[key: number]: LibraryType} = {};
/** /**
* If the Reading List is promoted or not * If the Reading List is promoted or not

View File

@ -89,7 +89,7 @@
@if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) { @if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
<div class="col-auto ms-2"> <div class="col-auto ms-2">
<button class="btn btn-actions" (click)="toggleScrobbling($event)" [ngbTooltip]="t('scrobbling-tooltip')"> <button class="btn btn-actions" (click)="toggleScrobbling($event)" [ngbTooltip]="t('scrobbling-tooltip', {value: isScrobbling ? t('on') : t('off')})">
<i class="fa-solid fa-tower-{{(isScrobbling) ? 'broadcast' : 'observation'}}" aria-hidden="true"></i> <i class="fa-solid fa-tower-{{(isScrobbling) ? 'broadcast' : 'observation'}}" aria-hidden="true"></i>
</button> </button>
</div> </div>

View File

@ -150,6 +150,16 @@ interface StoryLineItem {
}) })
export class SeriesDetailComponent implements OnInit, AfterContentChecked { export class SeriesDetailComponent implements OnInit, AfterContentChecked {
protected readonly LibraryType = LibraryType;
protected readonly TabID = TabID;
protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber;
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly SettingsTabId = SettingsTabId;
protected readonly FilterField = FilterField;
protected readonly AgeRating = AgeRating;
protected readonly Breakpoint = Breakpoint;
protected readonly encodeURIComponent = encodeURIComponent;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly seriesService = inject(SeriesService); private readonly seriesService = inject(SeriesService);
@ -180,14 +190,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
private readonly scrobbleService = inject(ScrobblingService); private readonly scrobbleService = inject(ScrobblingService);
private readonly location = inject(Location); private readonly location = inject(Location);
protected readonly LibraryType = LibraryType;
protected readonly TabID = TabID;
protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber;
protected readonly SpecialVolumeNumber = SpecialVolumeNumber;
protected readonly SettingsTabId = SettingsTabId;
protected readonly FilterField = FilterField;
protected readonly AgeRating = AgeRating;
protected readonly Breakpoint = Breakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -1212,6 +1214,4 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} }
}, 10); }, 10);
} }
protected readonly encodeURIComponent = encodeURIComponent;
} }

View File

@ -15,7 +15,7 @@
</div> </div>
<div class="col-auto text-end align-self-end justify-content-end edit-btn"> <div class="col-auto text-end align-self-end justify-content-end edit-btn">
@if (showEdit) { @if (showEdit) {
<button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit"> <button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}} {{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
</button> </button>
} }

View File

@ -15,7 +15,7 @@
</div> </div>
<div class="col-auto text-end align-self-end justify-content-end edit-btn"> <div class="col-auto text-end align-self-end justify-content-end edit-btn">
<button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button> <button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,24 +1,33 @@
<form [formGroup]="form" *transloco="let t"> <form [formGroup]="form" *transloco="let t">
<div formArrayName="items">
@for(item of Items; let i = $index; track item; let isFirst = $first) { @for(item of ItemsArray.controls; let i = $index; track i) {
<div class="row g-0 mb-3"> <div class="row g-0 mb-3">
<div class="col-lg-10 col-md-12 pe-2"> <div class="col-lg-10 col-md-12 pe-2">
<div class="mb-3"> <div class="mb-3">
<label for="item--{{i}}" class="visually-hidden">{{label}}</label> <label for="item--{{i}}" class="visually-hidden">{{label}}</label>
<input type="text" class="form-control" formControlName="link{{i}}" id="item--{{i}}"> <input
type="text"
class="form-control"
[formControlName]="i"
id="item--{{i}}"
>
</div>
</div>
<div class="col-lg-2">
<button class="btn btn-secondary me-1" (click)="add()">
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.add')}}</span>
</button>
<button
class="btn btn-secondary"
(click)="remove(i)"
[disabled]="ItemsArray.length === 1"
>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.remove')}}</span>
</button>
</div> </div>
</div> </div>
<div class="col-lg-2"> }
<button class="btn btn-secondary me-1" (click)="add()"> </div>
<i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.add')}}</span>
</button>
<button class="btn btn-secondary" (click)="remove(i)" [disabled]="isFirst">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">{{t('common.remove')}}</span>
</button>
</div>
</div>
}
</form> </form>

View File

@ -9,15 +9,14 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {CommonModule} from '@angular/common'; import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
@Component({ @Component({
selector: 'app-edit-list', selector: 'app-edit-list',
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective], imports: [ReactiveFormsModule, TranslocoDirective],
templateUrl: './edit-list.component.html', templateUrl: './edit-list.component.html',
styleUrl: './edit-list.component.scss', styleUrl: './edit-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -31,20 +30,16 @@ export class EditListComponent implements OnInit {
@Input({required: true}) label = ''; @Input({required: true}) label = '';
@Output() updateItems = new EventEmitter<Array<string>>(); @Output() updateItems = new EventEmitter<Array<string>>();
form: FormGroup = new FormGroup({}); form: FormGroup = new FormGroup({items: new FormArray([])});
private combinedItems: string = '';
get Items() { get ItemsArray(): FormArray {
return this.combinedItems.split(',') || ['']; return this.form.get('items') as FormArray;
} }
ngOnInit() { ngOnInit() {
this.items.forEach((link, index) => { this.items.forEach(item => this.addItem(item));
this.form.addControl('link' + index, new FormControl(link, []));
});
this.combinedItems = this.items.join(',');
this.form.valueChanges.pipe( this.form.valueChanges.pipe(
debounceTime(100), debounceTime(100),
@ -55,47 +50,39 @@ export class EditListComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
createItemControl(value: string = ''): FormControl {
return new FormControl(value, []);
}
add() { add() {
this.combinedItems += ','; this.ItemsArray.push(this.createItemControl());
this.form.addControl('link' + (this.Items.length - 1), new FormControl('', []));
this.emit(); this.emit();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
addItem(value: string) {
this.ItemsArray.push(this.createItemControl(value));
}
remove(index: number) { remove(index: number) {
// If it's the last item, just clear its value
const initialControls = Object.keys(this.form.controls) if (this.ItemsArray.length === 1) {
.filter(key => key.startsWith('link')); this.ItemsArray.at(0).setValue('');
if (index == 0 && initialControls.length === 1) {
this.form.get(initialControls[0])?.setValue('', {emitEvent: true});
this.emit(); this.emit();
this.cdRef.markForCheck();
return; return;
} }
// Remove the form control explicitly then rebuild the combinedItems this.ItemsArray.removeAt(index);
this.form.removeControl('link' + index, {emitEvent: true});
this.combinedItems = Object.keys(this.form.controls)
.filter(key => key.startsWith('link'))
.map(key => this.form.get(key)?.value)
.join(',');
// Recreate form to ensure index's match
this.form = new FormGroup({});
this.Items.forEach((item, index) => {
this.form.addControl('link' + index, new FormControl(item, []));
})
this.emit(); this.emit();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
// Emit non-empty item values
emit() { emit() {
this.updateItems.emit(Object.keys(this.form.controls) const nonEmptyItems = this.ItemsArray.controls
.filter(key => key.startsWith('link')) .map(control => control.value)
.map(key => this.form.get(key)?.value) .filter(value => value !== null && value.trim() !== '');
.filter(v => v !== null && v !== ''));
this.updateItems.emit(nonEmptyItems);
} }
} }

View File

@ -54,7 +54,17 @@ export class ReadMoreComponent implements OnChanges {
this.hideToggle = false; this.hideToggle = false;
if (this.isCollapsed) { if (this.isCollapsed) {
this.currentText = text.substring(0, this.maxLength); this.currentText = text.substring(0, this.maxLength);
this.currentText = this.currentText.substring(0, Math.min(this.currentText.length, this.currentText.lastIndexOf(' ')));
// Find last natural breaking point: space for English, or a CJK character boundary
const match = this.currentText.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+$/u);
const lastSpace = this.currentText.lastIndexOf(' ');
if (lastSpace > 0) {
this.currentText = this.currentText.substring(0, lastSpace); // Prefer space for English
} else if (match) {
this.currentText = this.currentText.substring(0, this.currentText.length - match[0].length); // Trim CJK
}
this.currentText = this.currentText + '…'; this.currentText = this.currentText + '…';
} else if (!this.isCollapsed) { } else if (!this.isCollapsed) {
this.currentText = text; this.currentText = text;
@ -62,6 +72,7 @@ export class ReadMoreComponent implements OnChanges {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
ngOnChanges() { ngOnChanges() {
this.determineView(); this.determineView();
} }

View File

@ -1,12 +1,4 @@
import { import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import { import {
NgbActiveModal, NgbActiveModal,
@ -244,12 +236,16 @@ export class LibrarySettingsModalComponent implements OnInit {
this.madeChanges = false; this.madeChanges = false;
// TODO: Refactor into FormArray
for(let fileTypeGroup of allFileTypeGroup) { for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), []));
} }
// TODO: Refactor into FormArray
for(let glob of this.library.excludePatterns) { for(let glob of this.library.excludePatterns) {
this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, [])); this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, []));
} }
this.excludePatterns = this.library.excludePatterns; this.excludePatterns = this.library.excludePatterns;
} else { } else {
for(let fileTypeGroup of allFileTypeGroup) { for(let fileTypeGroup of allFileTypeGroup) {

View File

@ -264,6 +264,18 @@
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
</div> </div>
<div class="row g-0 mt-4 mb-4">
<app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch"
formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
</ng-template>
</app-setting-switch>
</div>
</ng-container> </ng-container>
<div class="setting-section-break"></div> <div class="setting-section-break"></div>

View File

@ -110,7 +110,7 @@ export class ManageUserPreferencesComponent implements OnInit {
get Locale() { get Locale() {
if (!this.settingsForm.get('locale')) return 'English'; if (!this.settingsForm.get('locale')) return 'English';
return this.locales.filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName; return (this.locales || []).filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName;
} }
@ -154,6 +154,7 @@ export class ManageUserPreferencesComponent implements OnInit {
this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, []));
this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, [])); this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, []));
this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, [])); this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, []));
this.settingsForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.user.preferences.allowAutomaticWebtoonReaderDetection, []));
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
@ -226,6 +227,7 @@ export class ManageUserPreferencesComponent implements OnInit {
this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false}); this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false});
this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false}); this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false});
this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false}); this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false});
this.settingsForm.get('allowAutomaticWebtoonReaderDetection')?.setValue(this.user.preferences.allowAutomaticWebtoonReaderDetection, {onlySelf: true, emitEvent: false});
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false}); this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false});
this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false}); this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false});
@ -265,6 +267,7 @@ export class ManageUserPreferencesComponent implements OnInit {
readerMode: parseInt(modelSettings.readerMode, 10), readerMode: parseInt(modelSettings.readerMode, 10),
layoutMode: parseInt(modelSettings.layoutMode, 10), layoutMode: parseInt(modelSettings.layoutMode, 10),
showScreenHints: modelSettings.showScreenHints, showScreenHints: modelSettings.showScreenHints,
allowAutomaticWebtoonReaderDetection: modelSettings.allowAutomaticWebtoonReaderDetection,
backgroundColor: modelSettings.backgroundColor || '#000', backgroundColor: modelSettings.backgroundColor || '#000',
bookReaderFontFamily: modelSettings.bookReaderFontFamily, bookReaderFontFamily: modelSettings.bookReaderFontFamily,
bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, bookReaderLineSpacing: modelSettings.bookReaderLineSpacing,

View File

@ -156,6 +156,8 @@
"emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book",
"swipe-to-paginate-label": "Swipe to Paginate", "swipe-to-paginate-label": "Swipe to Paginate",
"swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered",
"allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode",
"allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.",
"book-reader-settings-title": "Book Reader", "book-reader-settings-title": "Book Reader",
"tap-to-paginate-label": "Tap to Paginate", "tap-to-paginate-label": "Tap to Paginate",
@ -973,9 +975,11 @@
"more-alt": "More", "more-alt": "More",
"time-left-alt": "Time Left", "time-left-alt": "Time Left",
"time-to-read-alt": "{{sort-field-pipe.time-to-read}}", "time-to-read-alt": "{{sort-field-pipe.time-to-read}}",
"scrobbling-tooltip": "{{settings.scrobbling}}", "scrobbling-tooltip": "{{settings.scrobbling}}: {{value}}",
"publication-status-title": "Publication", "publication-status-title": "Publication",
"publication-status-tooltip": "Publication Status" "publication-status-tooltip": "Publication Status",
"on": "{{reader-settings.on}}",
"off": "{{reader-settings.off}}"
}, },
"match-series-modal": { "match-series-modal": {
@ -1241,7 +1245,8 @@
"language-title": "{{edit-chapter-modal.language-label}}", "language-title": "{{edit-chapter-modal.language-label}}",
"release-title": "{{sort-field-pipe.release-year}}", "release-title": "{{sort-field-pipe.release-year}}",
"format-title": "{{metadata-filter.format-label}}", "format-title": "{{metadata-filter.format-label}}",
"length-title": "{{edit-chapter-modal.words-label}}" "length-title": "{{edit-chapter-modal.words-label}}",
"age-rating-title": "{{metadata-fields.age-rating-title}}"
}, },
"related-tab": { "related-tab": {
@ -1340,7 +1345,7 @@
"reset": "{{common.reset}}", "reset": "{{common.reset}}",
"test": "Test", "test": "Test",
"host-name-label": "Host Name", "host-name-label": "Host Name",
"host-name-tooltip": "Domain Name (of Reverse Proxy). Required for email functionality. If no reverse proxy, use any url.", "host-name-tooltip": "The domain name of your reverse proxy, required for email functionality. If youre not using a reverse proxy, you can use any URL, including http://externalip:port/",
"host-name-validation": "Host name must start with http(s) and not end in /", "host-name-validation": "Host name must start with http(s) and not end in /",
"sender-address-label": "Sender Address", "sender-address-label": "Sender Address",
@ -1751,7 +1756,19 @@
"read-options-alt": "Read options", "read-options-alt": "Read options",
"incognito-alt": "(Incognito)", "incognito-alt": "(Incognito)",
"no-data": "Nothing added", "no-data": "Nothing added",
"characters-title": "{{metadata-fields.characters-title}}" "characters-title": "{{metadata-fields.characters-title}}",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"publishers-title": "{{metadata-fields.publishers-title}}",
"items-title": "Items",
"storyline-tab": "{{series-detail.storyline-tab}}",
"details-tab": "{{series-detail.details-tab}}",
"edit-alt": "{{common.edit}}",
"edit-label": "Edit Mode",
"date-range-title": "Date Range",
"more-alt": "{{series-detail.more-alt}}",
"reorder-alt": "Reorder Items",
"dnd-warning": "Drag and drop is unavailable on mobile devices or when the reading list has more than 100 items."
}, },
"events-widget": { "events-widget": {
@ -2617,7 +2634,8 @@
"person-image-downloaded": "Person cover was downloaded and applied.", "person-image-downloaded": "Person cover was downloaded and applied.",
"bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?",
"match-success": "Series matched correctly", "match-success": "Series matched correctly",
"webtoon-override": "Switching to Webtoon mode due to images representing a webtoon." "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.",
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services."
}, },
"read-time-pipe": { "read-time-pipe": {

View File

@ -38,15 +38,55 @@
} }
.btn-outline-secondary { .btn-outline-secondary {
/**
--btn-secondary-text-color: white;
--btn-secondary-bg-color: #6c757d;
--btn-secondary-border-color: #6c757d;
--btn-secondary-hover-bg-color: var(--bs-btn-hover-bg);
--btn-secondary-hover-border-color: var(--bs-btn-hover-border-color);
--btn-secondary-hover-border-color: var(--bs-btn-hover-border-color);
--btn-secondary-font-weight: bold;
--btn-secondary-outline-text-color: white;
--btn-secondary-outline-bg-color: transparent;
--btn-secondary-outline-border-color: #6c757d;
--btn-secondary-outline-hover-bg-color: transparent;
--btn-secondary-outline-hover-border-color: transparent;
--btn-secondary-outline-font-weight: bold;
vs bootstrap
--bs-btn-color: var(--btn-secondary-bg-color);
--bs-btn-border-color: var(--btn-secondary-border-color);
--bs-btn-hover-color: var(--btn-secondary-hover-text-color);
--bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color);
--bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color);
--bs-btn-focus-shadow-rgb: 108, 117, 125;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: #6c757d;
--bs-btn-active-border-color: #6c757d;
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: #6c757d;
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: #6c757d;
--bs-gradient: none;
*/
// Override bootstrap variables
--bs-btn-color: var(--btn-secondary-bg-color);
--bs-btn-border-color: var(--btn-secondary-border-color);
--bs-btn-hover-color: var(--btn-secondary-hover-text-color);
--bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color);
--bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color);
color: var(--btn-secondary-outline-text-color); color: var(--btn-secondary-outline-text-color);
background-color: var(--btn-secondary-outline-bg-color); background-color: var(--btn-secondary-outline-bg-color);
border-color: var(--btn-secondary-outline-border-color); border-color: var(--btn-secondary-outline-border-color);
border-radius: 0;
&:hover { &:hover {
--bs-btn-color: var(--btn-secondary-outline-hover-text-color); --bs-btn-color: var(--btn-secondary-outline-hover-text-color);
--bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color); --bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color);
--bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color);
--btn-secondary-outline-text-color: var(--btn-secondary-outline-hover-bg-color);
color: var(--btn-secondary-outline-hover-text-color); color: var(--btn-secondary-outline-hover-text-color);
background-color: var(--btn-secondary-outline-hover-bg-color); background-color: var(--btn-secondary-outline-hover-bg-color);
@ -109,17 +149,6 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
} }
} }
.btn-text {
color: var(--primary-color);
> i {
color: var(--primary-color) !important;
}
&:hover, &:focus {
color: var(--primary-color);
}
}
.btn:focus, .btn:active, .btn:active:focus { .btn:focus, .btn:active, .btn:active:focus {
box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important; box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important;
@ -131,6 +160,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
color: var(--body-text-color); color: var(--body-text-color);
border: none; border: none;
&:disabled {
--bs-btn-disabled-bg: transparent;
}
&:hover, &:focus { &:hover, &:focus {
color: var(--body-text-color); color: var(--body-text-color);
border: none; border: none;

View File

@ -146,9 +146,10 @@
--btn-secondary-font-weight: bold; --btn-secondary-font-weight: bold;
--btn-secondary-outline-text-color: white; --btn-secondary-outline-text-color: white;
--btn-secondary-outline-bg-color: transparent; --btn-secondary-outline-bg-color: transparent;
--btn-secondary-outline-border-color: transparent; --btn-secondary-outline-border-color: #6c757d;
--btn-secondary-outline-hover-bg-color: transparent; --btn-secondary-outline-hover-text-color: #fff;
--btn-secondary-outline-hover-border-color: transparent; --btn-secondary-outline-hover-bg-color: var(--btn-secondary-bg-color);
--btn-secondary-outline-hover-border-color: var(--btn-secondary-bg-color);
--btn-secondary-outline-font-weight: bold; --btn-secondary-outline-font-weight: bold;
--btn-primary-text-text-color: white; --btn-primary-text-text-color: white;
--btn-secondary-text-text-color: lightgrey; --btn-secondary-text-text-color: lightgrey;