mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Reading List Detail Overhaul + More Bugfixes and Polish (#3687)
Co-authored-by: Yongun Seong <yseong.p@gmail.com>
This commit is contained in:
parent
b2ee651fb8
commit
dad212bfb9
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Helpers;
|
||||
@ -49,17 +50,14 @@ public class OrderableHelperTests
|
||||
[Fact]
|
||||
public void ReorderItems_InvalidPosition_NoChange()
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<AppUserSideNavStream>
|
||||
{
|
||||
new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" },
|
||||
new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" },
|
||||
};
|
||||
|
||||
// Act
|
||||
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(2, items[1].Id); // Item 2 should remain at position 1
|
||||
}
|
||||
@ -80,7 +78,6 @@ public class OrderableHelperTests
|
||||
[Fact]
|
||||
public void ReorderItems_DoubleMove()
|
||||
{
|
||||
// Arrange
|
||||
var items = new List<AppUserSideNavStream>
|
||||
{
|
||||
new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" },
|
||||
@ -94,7 +91,6 @@ public class OrderableHelperTests
|
||||
// Move 4 -> 1
|
||||
OrderableHelper.ReorderItems(items, 5, 1);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, items[0].Id);
|
||||
Assert.Equal(0, items[0].Order);
|
||||
Assert.Equal(5, items[1].Id);
|
||||
@ -109,4 +105,98 @@ public class OrderableHelperTests
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 /> 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/&#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/&#64;tamakiya\">Pawoo</a></p>"
|
||||
)]
|
||||
public void TestSquashBreaklines(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, StringHelper.SquashBreaklines(input));
|
||||
@ -28,4 +32,15 @@ public class StringHelperTests
|
||||
{
|
||||
Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input));
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(
|
||||
"""<a href=\"https://pawoo.net/&#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));
|
||||
}
|
||||
}
|
||||
|
@ -213,7 +213,6 @@ public class LibraryController : BaseApiController
|
||||
|
||||
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username);
|
||||
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
|
||||
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
|
||||
|
||||
return Ok(ret);
|
||||
}
|
||||
@ -419,8 +418,7 @@ public class LibraryController : BaseApiController
|
||||
.Distinct()
|
||||
.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath);
|
||||
|
||||
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder,
|
||||
new List<string>() {dto.FolderPath});
|
||||
var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]);
|
||||
|
||||
_taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath);
|
||||
|
||||
|
@ -46,8 +46,8 @@ public class LocaleController : BaseApiController
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -803,7 +803,7 @@ public class ReaderController : BaseApiController
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("time-left")]
|
||||
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId"])]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])]
|
||||
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
@ -6,6 +6,7 @@ using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
@ -23,13 +24,15 @@ public class ReadingListController : BaseApiController
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IReadingListService _readingListService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IReaderService _readerService;
|
||||
|
||||
public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService, IReaderService readerService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_readingListService = readingListService;
|
||||
_localizationService = localizationService;
|
||||
_readerService = readerService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -128,7 +131,7 @@ public class ReadingListController : BaseApiController
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
@ -452,26 +455,38 @@ public class ReadingListController : BaseApiController
|
||||
return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
|
||||
}
|
||||
|
||||
|
||||
/// <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>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("characters")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
|
||||
public ActionResult<IEnumerable<PersonDto>> GetCharactersForList(int readingListId)
|
||||
[HttpGet("all-people")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])]
|
||||
public async Task<ActionResult<IEnumerable<PersonDto>>> GetAllPeopleForList(int readingListId)
|
||||
{
|
||||
return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId));
|
||||
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next chapter within the reading list
|
||||
/// </summary>
|
||||
/// <param name="currentChapterId"></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")]
|
||||
public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId)
|
||||
{
|
||||
@ -577,4 +592,26 @@ public class ReadingListController : BaseApiController
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -238,7 +238,8 @@ public class SeriesController : BaseApiController
|
||||
// Trigger a refresh when we are moving from a locked image to a non-locked
|
||||
needsRefreshMetadata = true;
|
||||
series.CoverImage = null;
|
||||
series.CoverImageLocked = updateSeries.CoverImageLocked;
|
||||
series.CoverImageLocked = false;
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
|
||||
series.ResetColorScape();
|
||||
|
||||
}
|
||||
|
20
API/DTOs/ReadingLists/ReadingListCast.cs
Normal file
20
API/DTOs/ReadingLists/ReadingListCast.cs
Normal 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; } = [];
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs.ReadingLists;
|
||||
@ -43,6 +44,10 @@ public class ReadingListDto : IHasCoverImage
|
||||
/// Maximum Month the Reading List starts
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
|
26
API/DTOs/ReadingLists/ReadingListInfoDto.cs
Normal file
26
API/DTOs/ReadingLists/ReadingListInfoDto.cs
Normal 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; }
|
||||
}
|
@ -25,7 +25,7 @@ public class ReadingListItemDto
|
||||
/// <summary>
|
||||
/// Release Date from Chapter
|
||||
/// </summary>
|
||||
public DateTime ReleaseDate { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
/// <summary>
|
||||
/// Used internally only
|
||||
/// </summary>
|
||||
@ -33,7 +33,7 @@ public class ReadingListItemDto
|
||||
/// <summary>
|
||||
/// The last time a reading list item (underlying chapter) was read by current authenticated user
|
||||
/// </summary>
|
||||
public DateTime LastReadingProgressUtc { get; set; }
|
||||
public DateTime? LastReadingProgressUtc { get; set; }
|
||||
/// <summary>
|
||||
/// File size of underlying item
|
||||
/// </summary>
|
||||
|
@ -63,6 +63,13 @@ public class UserPreferencesDto
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow Automatic Webtoon detection
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override extra Margin
|
||||
/// </summary>
|
||||
|
@ -133,6 +133,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.WantToReadSync)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.AllowScrobbling)
|
||||
|
3403
API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs
generated
Normal file
3403
API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.1");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -353,6 +353,11 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAutomaticWebtoonReaderDetection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AniListScrobblingEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@ -911,24 +916,6 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -1640,7 +1627,7 @@ namespace API.Data.Migrations
|
||||
b.ToTable("MetadataFieldMapping");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
|
||||
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@ -1703,7 +1690,25 @@ namespace API.Data.Migrations
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@ -1747,6 +1752,32 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -2111,32 +2142,6 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Key")
|
||||
@ -2804,25 +2809,6 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -2943,7 +2929,7 @@ namespace API.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.MetadataSettings", "MetadataSettings")
|
||||
b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings")
|
||||
.WithMany("FieldMappings")
|
||||
.HasForeignKey("MetadataSettingsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@ -2952,6 +2938,44 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -3072,25 +3096,6 @@ namespace API.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("API.Entities.Series", "Series")
|
||||
@ -3351,12 +3356,12 @@ namespace API.Data.Migrations
|
||||
b.Navigation("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
|
||||
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
|
||||
{
|
||||
b.Navigation("FieldMappings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
modelBuilder.Entity("API.Entities.Person.Person", b =>
|
||||
{
|
||||
b.Navigation("ChapterPeople");
|
||||
|
||||
|
@ -1,10 +1,7 @@
|
||||
using System.Collections;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
@ -31,15 +28,13 @@ public interface IPersonRepository
|
||||
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
|
||||
Task RemoveAllPeopleNoLongerAssociated();
|
||||
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<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
|
||||
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
|
||||
Task<Person?> GetPersonById(int personId);
|
||||
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
|
||||
Task<Person> GetPersonByName(string name);
|
||||
Task<bool> IsNameUnique(string name);
|
||||
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
|
||||
@ -126,12 +121,8 @@ public class PersonRepository : IPersonRepository
|
||||
.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
|
||||
.Where(c => c.Id == personId)
|
||||
@ -139,7 +130,7 @@ public class PersonRepository : IPersonRepository
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<string> GetCoverImageByNameAsync(string name)
|
||||
public async Task<string?> GetCoverImageByNameAsync(string name)
|
||||
{
|
||||
var normalized = name.ToNormalized();
|
||||
return await _context.Person
|
||||
@ -208,7 +199,7 @@ public class PersonRepository : IPersonRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<PersonDto> GetPersonDtoByName(string name, int userId)
|
||||
public async Task<PersonDto?> GetPersonDtoByName(string name, int userId)
|
||||
{
|
||||
var normalized = name.ToNormalized();
|
||||
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||
@ -220,11 +211,6 @@ public class PersonRepository : IPersonRepository
|
||||
.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)
|
||||
{
|
||||
return !(await _context.Person.AnyAsync(p => p.Name == name));
|
||||
|
@ -49,12 +49,14 @@ public interface IReadingListRepository
|
||||
Task<IList<string>> GetRandomCoverImagesAsync(int readingListId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
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<int> RemoveReadingListsWithoutSeries();
|
||||
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>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items);
|
||||
Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId);
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
@ -121,12 +123,12 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.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
|
||||
.Where(item => item.ReadingListId == readingListId)
|
||||
.SelectMany(item => item.Chapter.People)
|
||||
.Where(p => p.Role == PersonRole.Character)
|
||||
.Where(p => p.Role == role)
|
||||
.OrderBy(p => p.Person.NormalizedName)
|
||||
.Select(p => p.Person)
|
||||
.Distinct()
|
||||
@ -134,6 +136,77 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.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)
|
||||
{
|
||||
var extension = encodeFormat.GetExtension();
|
||||
@ -181,6 +254,33 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.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)
|
||||
{
|
||||
|
@ -167,11 +167,11 @@ public class ScrobbleRepository : IScrobbleRepository
|
||||
var query = _context.ScrobbleEvent
|
||||
.Where(e => e.AppUserId == userId)
|
||||
.Include(e => e.Series)
|
||||
.SortBy(filter.Field, filter.IsDescending)
|
||||
.WhereIf(!string.IsNullOrEmpty(filter.Query), s =>
|
||||
EF.Functions.Like(s.Series.Name, $"%{filter.Query}%")
|
||||
)
|
||||
.WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review)
|
||||
.SortBy(filter.Field, filter.IsDescending)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider);
|
||||
|
||||
|
@ -54,6 +54,10 @@ public class AppUserPreferences
|
||||
/// Manga Reader Option: Should swiping trigger pagination
|
||||
/// </summary>
|
||||
public bool SwipeToPaginate { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow Automatic Webtoon detection
|
||||
/// </summary>
|
||||
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
|
@ -7,5 +7,6 @@ public enum ScrobbleEventSortField
|
||||
LastModified = 2,
|
||||
Type= 3,
|
||||
Series = 4,
|
||||
IsProcessed = 5
|
||||
IsProcessed = 5,
|
||||
ScrobbleEventFilter = 6
|
||||
}
|
||||
|
@ -255,6 +255,7 @@ public static class QueryableExtensions
|
||||
ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType),
|
||||
ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName),
|
||||
ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed),
|
||||
ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType),
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
@ -267,6 +268,7 @@ public static class QueryableExtensions
|
||||
ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType),
|
||||
ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName),
|
||||
ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed),
|
||||
ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType),
|
||||
_ => query
|
||||
};
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ public static partial class StringHelper
|
||||
private static partial Regex BrMultipleRegex();
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhiteSpaceRegex();
|
||||
[GeneratedRegex("&#64;")]
|
||||
private static partial Regex HtmlEncodedAtSymbolRegex();
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
@ -52,4 +54,16 @@ public static partial class StringHelper
|
||||
|
||||
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, "@");
|
||||
}
|
||||
}
|
||||
|
@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService
|
||||
{
|
||||
if (string.IsNullOrEmpty(series.CoverImage)) continue;
|
||||
series.CoverImage = series.GetCoverImage();
|
||||
if (series.CoverImage == null)
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
|
||||
}
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
@ -199,6 +199,10 @@ public class MetadataService : IMetadataService
|
||||
|
||||
series.Volumes ??= [];
|
||||
series.CoverImage = series.GetCoverImage();
|
||||
if (series.CoverImage == null)
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
|
||||
}
|
||||
|
||||
_imageService.UpdateColorScape(series);
|
||||
|
||||
|
@ -76,7 +76,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
};
|
||||
// Allow 50 requests per 24 hours
|
||||
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,
|
||||
ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService)
|
||||
@ -115,18 +115,24 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
// Find all Series that are eligible and limit
|
||||
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false);
|
||||
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 successfulMatches = new List<int>();
|
||||
var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids);
|
||||
foreach (var seriesId in ids)
|
||||
{
|
||||
var libraryType = libTypes[seriesId];
|
||||
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
|
||||
}
|
||||
_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))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -731,7 +737,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
Name = w.Name,
|
||||
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
|
||||
.Where(p => p.Role == PersonRole.Character)
|
||||
@ -743,7 +749,9 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
.ToList();
|
||||
|
||||
if (characters.Count == 0) return false;
|
||||
|
||||
await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork);
|
||||
|
||||
foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character))
|
||||
{
|
||||
// Set a sort order based on their role
|
||||
@ -810,7 +818,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
Name = w.Name,
|
||||
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
|
||||
.Where(p => p.Role == PersonRole.CoverArtist)
|
||||
@ -867,7 +875,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
Name = w.Name,
|
||||
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
|
||||
.Where(p => p.Role == PersonRole.Writer)
|
||||
|
@ -552,14 +552,22 @@ public class CoverDbService : ICoverDbService
|
||||
|
||||
series.CoverImage = filePath;
|
||||
series.CoverImageLocked = true;
|
||||
if (series.CoverImage == null)
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
|
||||
}
|
||||
_imageService.UpdateColorScape(series);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
series.CoverImage = string.Empty;
|
||||
series.CoverImage = null;
|
||||
series.CoverImageLocked = false;
|
||||
if (series.CoverImage == null)
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
|
||||
}
|
||||
_imageService.UpdateColorScape(series);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
@ -278,7 +278,8 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
// Attempt to fetch from cache
|
||||
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)
|
||||
{
|
||||
@ -338,6 +339,29 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
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()
|
||||
{
|
||||
if (!File.Exists(_cacheFilePath)) return null;
|
||||
@ -370,7 +394,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(updates, JsonOptions);
|
||||
var json = JsonSerializer.Serialize(updates, JsonOptions);
|
||||
await File.WriteAllTextAsync(_cacheFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -542,6 +542,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz",
|
||||
"integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -569,6 +570,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@ -583,6 +585,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
@ -4904,7 +4907,8 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"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": {
|
||||
"version": "8.3.6",
|
||||
@ -5351,6 +5355,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -5360,6 +5365,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -8178,7 +8184,8 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"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": {
|
||||
"version": "7.1.0",
|
||||
@ -8399,7 +8406,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.0",
|
||||
@ -8464,6 +8471,7 @@
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@ -9088,6 +9096,7 @@
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -1,12 +1,11 @@
|
||||
|
||||
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
|
||||
import { BookPageLayoutMode } from '../readers/book-page-layout-mode';
|
||||
import { PageLayoutMode } from '../page-layout-mode';
|
||||
import { PageSplitOption } from './page-split-option';
|
||||
import { ReaderMode } from './reader-mode';
|
||||
import { ReadingDirection } from './reading-direction';
|
||||
import { ScalingOption } from './scaling-option';
|
||||
import { SiteTheme } from './site-theme';
|
||||
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
|
||||
import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
|
||||
import {PageLayoutMode} from '../page-layout-mode';
|
||||
import {PageSplitOption} from './page-split-option';
|
||||
import {ReaderMode} from './reader-mode';
|
||||
import {ReadingDirection} from './reading-direction';
|
||||
import {ScalingOption} from './scaling-option';
|
||||
import {SiteTheme} from './site-theme';
|
||||
import {WritingStyle} from "./writing-style";
|
||||
import {PdfTheme} from "./pdf-theme";
|
||||
import {PdfScrollMode} from "./pdf-scroll-mode";
|
||||
@ -25,6 +24,7 @@ export interface Preferences {
|
||||
showScreenHints: boolean;
|
||||
emulateBook: boolean;
|
||||
swipeToPaginate: boolean;
|
||||
allowAutomaticWebtoonReaderDetection: boolean;
|
||||
|
||||
// Book Reader
|
||||
bookReaderMargin: number;
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { LibraryType } from "./library/library";
|
||||
import { MangaFormat } from "./manga-format";
|
||||
import {LibraryType} from "./library/library";
|
||||
import {MangaFormat} from "./manga-format";
|
||||
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 {
|
||||
pagesRead: number;
|
||||
@ -39,4 +42,16 @@ export interface ReadingList extends IHasCover {
|
||||
endingYear: number;
|
||||
endingMonth: 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 {}
|
||||
|
@ -4,7 +4,8 @@ export enum ScrobbleEventSortField {
|
||||
LastModified = 2,
|
||||
Type= 3,
|
||||
Series = 4,
|
||||
IsProcessed = 5
|
||||
IsProcessed = 5,
|
||||
ScrobbleEvent = 6
|
||||
}
|
||||
|
||||
export interface ScrobbleEventFilter {
|
||||
|
@ -1,6 +1,5 @@
|
||||
export interface HourEstimateRange{
|
||||
export interface HourEstimateRange {
|
||||
minHours: number;
|
||||
maxHours: number;
|
||||
avgHours: number;
|
||||
//hasProgress: boolean;
|
||||
}
|
@ -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 {DOCUMENT, Location} from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { HourEstimateRange } from '../_models/series-detail/hour-estimate-range';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/readers/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/readers/progress-bookmark';
|
||||
import { FileDimension } from '../manga-reader/_models/file-dimension';
|
||||
import {Router} from '@angular/router';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {ChapterInfo} from '../manga-reader/_models/chapter-info';
|
||||
import {Chapter} from '../_models/chapter';
|
||||
import {HourEstimateRange} from '../_models/series-detail/hour-estimate-range';
|
||||
import {MangaFormat} from '../_models/manga-format';
|
||||
import {BookmarkInfo} from '../_models/manga-reader/bookmark-info';
|
||||
import {PageBookmark} from '../_models/readers/page-bookmark';
|
||||
import {ProgressBookmark} from '../_models/readers/progress-bookmark';
|
||||
import {FileDimension} from '../manga-reader/_models/file-dimension';
|
||||
import screenfull from 'screenfull';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { AccountService } from './account.service';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {AccountService} from './account.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
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 {translate} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {getIosVersion, isSafari, Version} from "../_helpers/browser";
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
getBookmarks(chapterId: number) {
|
||||
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Person } from '../_models/metadata/person';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { ReadingList, ReadingListItem } from '../_models/reading-list';
|
||||
import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UtilityService} from '../shared/_services/utility.service';
|
||||
import {Person, PersonRole} from '../_models/metadata/person';
|
||||
import {PaginatedResult} from '../_models/pagination';
|
||||
import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list';
|
||||
import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {Action, ActionItem} from './action-factory.service';
|
||||
|
||||
@Injectable({
|
||||
@ -20,7 +20,7 @@ export class ReadingListService {
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
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) {
|
||||
@ -114,10 +114,20 @@ export class ReadingListService {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
|
||||
}
|
||||
|
||||
getCharacters(readingListId: number) {
|
||||
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
|
||||
getPeople(readingListId: number, role: PersonRole) {
|
||||
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) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {filter, take} from "rxjs/operators";
|
||||
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
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 {Router} from "@angular/router";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -15,6 +16,7 @@ export class VersionService implements OnDestroy{
|
||||
private readonly serverService = inject(ServerService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
public static readonly SERVER_VERSION_KEY = 'kavita--version';
|
||||
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
|
||||
@ -29,15 +31,23 @@ export class VersionService implements OnDestroy{
|
||||
// Check intervals
|
||||
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_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 outOfDateCheckSubscription?: Subscription;
|
||||
private modalOpen = false;
|
||||
|
||||
constructor() {
|
||||
this.startInitialVersionCheck();
|
||||
this.startVersionCheck();
|
||||
this.startOutOfDateCheck();
|
||||
}
|
||||
@ -47,6 +57,26 @@ export class VersionService implements OnDestroy{
|
||||
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
|
||||
*/
|
||||
@ -76,12 +106,26 @@ export class VersionService implements OnDestroy{
|
||||
.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
|
||||
*/
|
||||
private handleVersionUpdate(serverVersion: string) {
|
||||
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);
|
||||
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);
|
||||
|
||||
|
@ -28,12 +28,24 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ageRating) {
|
||||
<div class="mb-3 ms-1">
|
||||
<h4 class="header">{{t('age-rating-title')}}</h4>
|
||||
<div class="ms-3">
|
||||
<app-age-rating-image [rating]="ageRating" />
|
||||
</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>
|
||||
|
||||
|
@ -20,6 +20,8 @@ import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
|
||||
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
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({
|
||||
selector: 'app-details-tab',
|
||||
@ -34,7 +36,8 @@ import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
MangaFormatPipe,
|
||||
LanguageNamePipe,
|
||||
AsyncPipe,
|
||||
SafeUrlPipe
|
||||
SafeUrlPipe,
|
||||
AgeRatingImageComponent
|
||||
],
|
||||
templateUrl: './details-tab.component.html',
|
||||
styleUrl: './details-tab.component.scss',
|
||||
@ -47,11 +50,13 @@ export class DetailsTabComponent {
|
||||
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly FilterField = FilterField;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
@Input({required: true}) metadata!: IHasCast;
|
||||
@Input() readingTime: IHasReadingTime | undefined;
|
||||
@Input() ageRating: AgeRating | undefined;
|
||||
@Input() language: string | undefined;
|
||||
@Input() format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
@Input() format: MangaFormat | undefined;
|
||||
@Input() releaseYear: number | undefined;
|
||||
@Input() genres: Array<Genre> = [];
|
||||
@Input() tags: Array<Tag> = [];
|
||||
@ -62,6 +67,4 @@ export class DetailsTabComponent {
|
||||
if (queryParamName === FilterField.None) return;
|
||||
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
|
||||
}
|
||||
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="ms-1">
|
||||
<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) {
|
||||
{{synm}}
|
||||
@if (!last) {
|
||||
|
@ -8,9 +8,7 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
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 {PercentPipe} from "@angular/common";
|
||||
import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
|
||||
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
|
@ -35,6 +35,7 @@
|
||||
[count]="pageInfo.totalElements"
|
||||
[offset]="pageInfo.pageNumber"
|
||||
[limit]="pageInfo.size"
|
||||
[sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]"
|
||||
>
|
||||
|
||||
<ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
|
||||
|
@ -10,7 +10,7 @@ import {debounceTime, take} from "rxjs/operators";
|
||||
import {PaginatedResult} from "../../_models/pagination";
|
||||
import {SortEvent} from "../table/_directives/sortable-header.directive";
|
||||
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 {TranslocoLocaleModule} from "@jsverse/transloco-locale";
|
||||
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 {AsyncPipe} from "@angular/common";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
export interface DataTablePage {
|
||||
pageNumber: number,
|
||||
@ -44,6 +45,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
|
||||
|
||||
@ -60,6 +62,10 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
totalElements: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
private currentSort: SortEvent<ScrobbleEvent> = {
|
||||
column: 'lastModifiedUtc',
|
||||
direction: 'desc'
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
@ -73,26 +79,26 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
|
||||
this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
|
||||
this.loadPage();
|
||||
})
|
||||
});
|
||||
|
||||
this.loadPage(this.currentSort);
|
||||
}
|
||||
|
||||
onPageChange(pageInfo: any) {
|
||||
this.pageInfo.pageNumber = pageInfo.offset;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.loadPage();
|
||||
this.loadPage(this.currentSort);
|
||||
}
|
||||
|
||||
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>) {
|
||||
if (sortEvent && this.pageInfo) {
|
||||
this.pageInfo.pageNumber = 1;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
const page = (this.pageInfo?.pageNumber || 0) + 1;
|
||||
const pageSize = this.pageInfo?.size || 0;
|
||||
const isDescending = sortEvent?.direction === 'desc';
|
||||
@ -102,7 +108,6 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// BUG: Table should be sorted by lastModifiedUtc by default
|
||||
this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
|
||||
.pipe(take(1))
|
||||
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
|
||||
@ -122,13 +127,14 @@ export class UserScrobbleHistoryComponent implements OnInit {
|
||||
case 'isProcessed': return ScrobbleEventSortField.IsProcessed;
|
||||
case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified;
|
||||
case 'seriesName': return ScrobbleEventSortField.Series;
|
||||
case 'scrobbleEventType': return ScrobbleEventSortField.ScrobbleEvent;
|
||||
}
|
||||
return ScrobbleEventSortField.None;
|
||||
}
|
||||
|
||||
generateScrobbleEvents() {
|
||||
this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => {
|
||||
|
||||
this.toastr.info(translate('toasts.scrobble-gen-init'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -34,12 +34,12 @@
|
||||
</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>
|
||||
{{t('created-header')}}
|
||||
</ng-template>
|
||||
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
|
||||
{{item.createdUtc | utcToLocalTime | defaultValue }}
|
||||
{{item.created | utcToLocalTime | defaultValue }}
|
||||
</ng-template>
|
||||
</ngx-datatable-column>
|
||||
|
||||
@ -57,9 +57,9 @@
|
||||
{{t('edit-header')}}
|
||||
</ng-template>
|
||||
<ng-template let-item="row" ngx-datatable-cell-template>
|
||||
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
|
||||
<i class="fa fa-pen me-1" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
|
||||
<button class="btn btn-icon" (click)="fixMatch(item.seriesId)">
|
||||
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</ngx-datatable-column>
|
||||
|
@ -20,15 +20,13 @@ import {ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {ScrobbleError} from "../../_models/scrobbling/scrobble-error";
|
||||
|
||||
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 {TranslocoModule} from "@jsverse/transloco";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-scrobble-errors',
|
||||
@ -38,14 +36,20 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManageScrobbleErrorsComponent implements OnInit {
|
||||
@Output() scrobbleCount = new EventEmitter<number>();
|
||||
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>;
|
||||
protected readonly filter = filter;
|
||||
protected readonly ColumnMode = ColumnMode;
|
||||
|
||||
private readonly scrobbleService = inject(ScrobblingService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
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());
|
||||
currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'});
|
||||
@ -58,8 +62,6 @@ export class ManageScrobbleErrorsComponent implements OnInit {
|
||||
});
|
||||
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
this.loadData();
|
||||
@ -108,13 +110,13 @@ export class ManageScrobbleErrorsComponent implements OnInit {
|
||||
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 => {
|
||||
const modalRef = this.modalService.open(EditSeriesModalComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.series = series;
|
||||
this.actionService.matchSeries(series, (result) => {
|
||||
if (!result) return;
|
||||
this.data = [...this.data.filter(s => s.seriesId !== series.id)];
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly filter = filter;
|
||||
protected readonly ColumnMode = ColumnMode;
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ import {AccountService} from "../../../_services/account.service";
|
||||
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton, NgbAccordionCollapse,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse,
|
||||
NgbAccordionDirective,
|
||||
NgbAccordionHeader,
|
||||
NgbAccordionItem
|
||||
@ -32,7 +33,7 @@ export class ChangelogComponent implements OnInit {
|
||||
isLoading: boolean = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.serverService.getChangelog(30).subscribe(updates => {
|
||||
this.serverService.getChangelog(7).subscribe(updates => {
|
||||
this.updates = updates;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -5,13 +5,16 @@
|
||||
{{t('description-part-1')}} <a [href]="WikiLink.SeriesRelationships" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{t('description-part-2')}}</a>
|
||||
</p>
|
||||
|
||||
<div class="row g-0" *ngIf="relations.length > 0">
|
||||
@if (relations.length > 0) {
|
||||
<div class="row">
|
||||
<label class="form-label col-md-7">{{t('target-series')}}</label>
|
||||
<label class="form-label col-md-5">{{t('relationship')}}</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<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="row">
|
||||
<div class="col-sm-12 col-md-12 col-lg-7 mb-3">
|
||||
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
@ -30,12 +33,16 @@
|
||||
<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>
|
||||
@for(opt of relationOptions; track opt) {
|
||||
<option [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>
|
||||
<i class="fa fa-trash" aria-hidden="true"></i><span class="visually-hidden">{{t('remove')}}</span></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
|
||||
<div class="row g-0 mt-3 mb-3">
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
@ -9,19 +10,18 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {FormControl, ReactiveFormsModule} from '@angular/forms';
|
||||
import { map, Observable, of, firstValueFrom, ReplaySubject } from 'rxjs';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings';
|
||||
import { SearchResult } from 'src/app/_models/search/search-result';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SearchService } from 'src/app/_services/search.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import {firstValueFrom, map, Observable, of, ReplaySubject} from 'rxjs';
|
||||
import {UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
|
||||
import {SearchResult} from 'src/app/_models/search/search-result';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {RelationKind, RelationKinds} from 'src/app/_models/series-detail/relation-kind';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {SearchService} from 'src/app/_services/search.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {TranslocoModule} from "@jsverse/transloco";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {WikiLink} from "../../_models/wiki";
|
||||
@ -36,7 +36,6 @@ interface RelationControl {
|
||||
selector: 'app-edit-series-relation',
|
||||
imports: [
|
||||
TypeaheadComponent,
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslocoModule,
|
||||
RelationshipPipe,
|
||||
@ -113,7 +112,8 @@ export class EditSeriesRelationComponent implements OnInit {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Focus on the new typeahead
|
||||
|
@ -66,7 +66,7 @@
|
||||
|
||||
<div class="card-title-container">
|
||||
<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}}">
|
||||
{{series.name}}
|
||||
</a>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common';
|
||||
import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
@ -14,15 +15,16 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
Renderer2,
|
||||
SimpleChanges, ViewChild
|
||||
SimpleChanges,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { ReaderService } from '../../../_services/reader.service';
|
||||
import { PAGING_DIRECTION } from '../../_models/reader-enums';
|
||||
import { WebtoonImage } from '../../_models/webtoon-image';
|
||||
import { MangaReaderService } from '../../_service/manga-reader.service';
|
||||
import {debounceTime} from 'rxjs/operators';
|
||||
import {ScrollService} from 'src/app/_services/scroll.service';
|
||||
import {ReaderService} from '../../../_services/reader.service';
|
||||
import {PAGING_DIRECTION} from '../../_models/reader-enums';
|
||||
import {WebtoonImage} from '../../_models/webtoon-image';
|
||||
import {MangaReaderService} from '../../_service/manga-reader.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {InfiniteScrollModule} from "ngx-infinite-scroll";
|
||||
@ -352,17 +354,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
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
|
||||
// to mark the current page and separate the prefetching code.
|
||||
const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
|
||||
.filter(entry => this.shouldElementCountAsCurrentPage(entry));
|
||||
|
||||
if (midlineImages.length > 0) {
|
||||
this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// // to mark the current page and separate the prefetching code.
|
||||
// const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
|
||||
// .filter(entry => this.shouldElementCountAsCurrentPage(entry));
|
||||
//
|
||||
// if (midlineImages.length > 0) {
|
||||
// this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Check if we hit the last page
|
||||
this.checkIfShouldTriggerContinuousReader();
|
||||
}
|
||||
@ -426,8 +428,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
this.checkIfShouldTriggerContinuousReader()
|
||||
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
|
||||
// This if statement will fire once we scroll into the spacer at all
|
||||
this.loadNextChapter.emit();
|
||||
this.cdRef.markForCheck();
|
||||
this.moveToNextChapter();
|
||||
}
|
||||
} else {
|
||||
// < 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;
|
||||
requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader));
|
||||
} 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.cdRef.markForCheck();
|
||||
}
|
||||
@ -597,7 +598,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
|
||||
handleBottomIntersection(entries: IntersectionObserverEntry[]) {
|
||||
if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) {
|
||||
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.
|
||||
* @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter.
|
||||
|
@ -621,11 +621,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
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(() => {
|
||||
this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
|
||||
this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
|
||||
@ -640,6 +635,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.navService.showSideNav();
|
||||
this.showBookmarkEffectEvent.complete();
|
||||
if (this.goToPageEvent !== undefined) this.goToPageEvent.complete();
|
||||
|
||||
this.readerService.disableWakeLock();
|
||||
}
|
||||
|
||||
@ -784,6 +780,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
switchToWebtoonReaderIfPagesLikelyWebtoon() {
|
||||
if (this.readerMode === ReaderMode.Webtoon) return;
|
||||
if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return;
|
||||
|
||||
if (this.mangaReaderService.shouldBeWebtoonMode()) {
|
||||
this.readerMode = ReaderMode.Webtoon;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { ElementRef, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
|
||||
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
|
||||
import { ScalingOption } from 'src/app/_models/preferences/scaling-option';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { ChapterInfo } from '../_models/chapter-info';
|
||||
import { DimensionMap } from '../_models/file-dimension';
|
||||
import { FITTING_OPTION } from '../_models/reader-enums';
|
||||
import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info';
|
||||
import {ElementRef, Injectable, Renderer2, RendererFactory2} from '@angular/core';
|
||||
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
|
||||
import {ScalingOption} from 'src/app/_models/preferences/scaling-option';
|
||||
import {ReaderService} from 'src/app/_services/reader.service';
|
||||
import {ChapterInfo} from '../_models/chapter-info';
|
||||
import {DimensionMap} from '../_models/file-dimension';
|
||||
import {FITTING_OPTION} from '../_models/reader-enums';
|
||||
import {BookmarkInfo} from 'src/app/_models/manga-reader/bookmark-info';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -113,10 +113,12 @@ export class MangaReaderService {
|
||||
return !(this.isNoSplit(pageSplitOption) || !needsSplitting)
|
||||
}
|
||||
|
||||
/**
|
||||
* Some pages aren't cover images but might need fit split renderings
|
||||
* @param pageSplitOption
|
||||
*/
|
||||
shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) {
|
||||
// Some pages aren't cover images but might need fit split renderings
|
||||
if (parseInt(pageSplitOption + '', 10) !== PageSplitOption.FitSplit) return false;
|
||||
return true;
|
||||
return parseInt(pageSplitOption + '', 10) === PageSplitOption.FitSplit;
|
||||
}
|
||||
|
||||
|
||||
@ -156,27 +158,97 @@ export class MangaReaderService {
|
||||
shouldBeWebtoonMode() {
|
||||
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 strongIndicatorCount = 0;
|
||||
|
||||
pages.forEach(info => {
|
||||
const aspectRatio = info.height / info.width;
|
||||
let score = 0;
|
||||
|
||||
// Strong webtoon indicator: If aspect ratio is at least 2:1
|
||||
if (aspectRatio >= 2) {
|
||||
if (aspectRatio >= 2.2) {
|
||||
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) {
|
||||
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;
|
||||
});
|
||||
|
||||
const averageScore = webtoonScore / pages.length;
|
||||
|
||||
// If at least 50% of the pages fit the webtoon criteria, switch to Webtoon mode.
|
||||
return webtoonScore / pages.length >= 0.5;
|
||||
// Multiple criteria for more robust detection
|
||||
// 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
<ng-container *transloco="let t; read: 'draggable-ordered-list'">
|
||||
|
||||
<ng-container *ngIf="items.length > virtualizeAfter; else dragList">
|
||||
@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">
|
||||
@ -14,46 +13,55 @@
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #dragList>
|
||||
} @else {
|
||||
<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
|
||||
@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>
|
||||
}
|
||||
|
||||
<ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized">
|
||||
<div class="me-3 align-middle">
|
||||
<div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}" *ngIf="accessibilityMode || bulkMode">
|
||||
<ng-container *ngIf="accessibilityMode">
|
||||
@if (accessibilityMode || bulkMode) {
|
||||
<div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}">
|
||||
@if (accessibilityMode) {
|
||||
<label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
|
||||
<input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0"
|
||||
[max]="items.length - 1" [value]="item.order"
|
||||
(focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions">
|
||||
</ng-container>
|
||||
<ng-container *ngIf="bulkMode">
|
||||
}
|
||||
|
||||
@if (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)"
|
||||
[ngModel]="bulkSelectionService.isCardSelected('sideNavStream', idx)" [ngModelOptions]="{standalone: true}">
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
|
||||
</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>
|
||||
</ng-template>
|
||||
|
||||
<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>
|
||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span>
|
||||
</button>
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
TrackByFunction
|
||||
} from '@angular/core';
|
||||
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 {BulkSelectionService} from "../../../cards/bulk-selection.service";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
@ -36,11 +36,15 @@ export interface ItemRemoveEvent {
|
||||
templateUrl: './draggable-ordered-list.component.html',
|
||||
styleUrls: ['./draggable-ordered-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag,
|
||||
imports: [VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag,
|
||||
CdkDragHandle, TranslocoDirective, NgClass, FormsModule]
|
||||
})
|
||||
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
|
||||
*/
|
||||
@ -59,6 +63,10 @@ export class DraggableOrderedListComponent {
|
||||
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
|
||||
*/
|
||||
@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
|
||||
*/
|
||||
@ -71,8 +79,6 @@ export class DraggableOrderedListComponent {
|
||||
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
|
||||
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
get BufferAmount() {
|
||||
return Math.min(this.items.length / 20, 20);
|
||||
|
@ -1,70 +1,52 @@
|
||||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read: 'reading-list-detail'">
|
||||
<app-side-nav-companion-bar [hasExtras]="readingList !== undefined" [extraDrawer]="extrasDrawer">
|
||||
<h4 title>
|
||||
{{readingList?.title}}
|
||||
@if (readingList?.promoted) {
|
||||
<span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
|
||||
}
|
||||
@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>
|
||||
<form [formGroup]="formGroup">
|
||||
@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 [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock>
|
||||
<div class="row mb-0 mb-xl-3 info-container">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
|
||||
<h4 class="title mb-2">
|
||||
<span>{{readingList.title}}
|
||||
@if (readingList.promoted) {
|
||||
(<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>)
|
||||
}
|
||||
|
||||
@if( isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">loading...</span>
|
||||
</div>
|
||||
}
|
||||
</span>
|
||||
<span class="read-btn--text"> {{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>
|
||||
}
|
||||
</ng-template>
|
||||
</app-side-nav-companion-bar>
|
||||
</h4>
|
||||
|
||||
@if (readingList) {
|
||||
<div class="container-fluid mt-2">
|
||||
<!-- <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="row mb-2">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
||||
<app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0 mb-3">
|
||||
|
||||
<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-primary" (click)="continue()">
|
||||
<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-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<button type="button" class="btn btn-outline-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read()">
|
||||
<span>
|
||||
@ -92,11 +74,41 @@
|
||||
</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) {
|
||||
<div class="row g-0 mt-2">
|
||||
<h4 class="reading-list-years">
|
||||
@if (readingList.startingMonth > 0) {
|
||||
{{(readingList.startingMonth +'/01/2020')| date:'MMM'}}
|
||||
}
|
||||
@ -118,33 +130,90 @@
|
||||
{{readingList.endingYear}}
|
||||
}
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
} @else {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
|
||||
@if (characters$ | async; as characters) {
|
||||
@if (characters && characters.length > 0) {
|
||||
<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">
|
||||
<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>
|
||||
<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 class="carousel-tabs-container mb-2">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
||||
|
||||
@if (showStorylineTab) {
|
||||
<li [ngbNavItem]="TabID.Storyline">
|
||||
<a ngbNavLink>{{t(TabID.Storyline)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabID.Storyline; prefetch on idle) {
|
||||
<div class="row mb-1 scroll-container" #scrollingBlock>
|
||||
@if (items.length === 0 && !isLoading) {
|
||||
<div class="mx-auto" style="width: 200px;">
|
||||
@ -154,15 +223,44 @@
|
||||
<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"
|
||||
[showRemoveButton]="false">
|
||||
[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)"></app-reading-list-item>
|
||||
[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 [ngbNavOutlet]="nav" style="min-height: 300px"></div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@ -1,3 +1,6 @@
|
||||
@use '../../../../series-detail-common';
|
||||
|
||||
|
||||
.main-container {
|
||||
margin-top: 10px;
|
||||
padding: 0 0 0 10px;
|
||||
|
@ -1,12 +1,23 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {
|
||||
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 {take} from 'rxjs/operators';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {LibraryType} from 'src/app/_models/library/library';
|
||||
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 {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {ActionService} from 'src/app/_services/action.service';
|
||||
@ -16,57 +27,84 @@ import {
|
||||
DraggableOrderedListComponent,
|
||||
IndexUpdateEvent
|
||||
} 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 {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 {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||
import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.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 {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
NgbDropdown,
|
||||
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 {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 {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({
|
||||
selector: 'app-reading-list-detail',
|
||||
templateUrl: './reading-list-detail.component.html',
|
||||
styleUrls: ['./reading-list-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, NgbDropdown,
|
||||
imports: [CardActionablesComponent, ImageComponent, NgbDropdown,
|
||||
NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent,
|
||||
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 {
|
||||
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private readingListService = inject(ReadingListService);
|
||||
private actionService = inject(ActionService);
|
||||
private actionFactoryService = inject(ActionFactoryService);
|
||||
public utilityService = inject(UtilityService);
|
||||
public imageService = inject(ImageService);
|
||||
protected utilityService = inject(UtilityService);
|
||||
protected imageService = inject(ImageService);
|
||||
private accountService = inject(AccountService);
|
||||
private toastr = inject(ToastrService);
|
||||
private confirmService = inject(ConfirmService);
|
||||
private libraryService = inject(LibraryService);
|
||||
private readerService = inject(ReaderService);
|
||||
private cdRef = inject(ChangeDetectorRef);
|
||||
private filterUtilityService = inject(FilterUtilitiesService);
|
||||
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> = [];
|
||||
listId!: number;
|
||||
@ -75,12 +113,62 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
isAdmin: boolean = false;
|
||||
isLoading: boolean = false;
|
||||
accessibilityMode: boolean = false;
|
||||
editMode: boolean = false;
|
||||
readingListSummary: string = '';
|
||||
|
||||
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 {
|
||||
@ -92,9 +180,45 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.editMode = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
forkJoin([
|
||||
@ -104,7 +228,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
const libraries = results[0];
|
||||
const readingList = results[1];
|
||||
|
||||
this.titleService.setTitle('Kavita - ' + readingList.title);
|
||||
|
||||
|
||||
libraries.forEach(lib => {
|
||||
this.libraryTypes[lib.id] = lib.type;
|
||||
@ -116,8 +240,10 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
this.router.navigateByUrl('library');
|
||||
return;
|
||||
}
|
||||
|
||||
this.readingList = readingList;
|
||||
this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>');
|
||||
this.titleService.setTitle('Kavita - ' + readingList.title);
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -127,10 +253,12 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
|
||||
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
|
||||
.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.getListItems();
|
||||
}
|
||||
|
||||
@ -163,14 +291,7 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
await this.deleteList(readingList);
|
||||
break;
|
||||
case Action.Edit:
|
||||
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();
|
||||
});
|
||||
});
|
||||
this.editReadingList(readingList);
|
||||
break;
|
||||
case Action.Promote:
|
||||
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) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
|
||||
|
||||
@ -260,7 +392,32 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -17,18 +17,23 @@
|
||||
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
|
||||
{{item.title}}
|
||||
<div class="actions float-end">
|
||||
@if (showRemove) {
|
||||
<button class="btn btn-danger" (click)="remove.emit(item)">
|
||||
<span>
|
||||
<i class="fa fa-trash me-1" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="d-none d-md-inline-block">{{t('remove')}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary ms-2" (click)="readChapter(item)">
|
||||
}
|
||||
|
||||
@if (showRead) {
|
||||
<button class="btn btn-outline-primary ms-2" (click)="readChapter(item)">
|
||||
<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>
|
||||
|
||||
</h5>
|
||||
|
@ -24,6 +24,8 @@ export class ReadingListItemComponent {
|
||||
|
||||
@Input({required: true}) item!: ReadingListItem;
|
||||
@Input() position: number = 0;
|
||||
@Input() showRemove: boolean = false;
|
||||
@Input() showRead: boolean = true;
|
||||
@Input() libraryTypes: {[key: number]: LibraryType} = {};
|
||||
/**
|
||||
* If the Reading List is promoted or not
|
||||
|
@ -89,7 +89,7 @@
|
||||
|
||||
@if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) {
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -150,6 +150,16 @@ interface StoryLineItem {
|
||||
})
|
||||
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 route = inject(ActivatedRoute);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
@ -180,14 +190,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
private readonly scrobbleService = inject(ScrobblingService);
|
||||
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('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||
@ -1212,6 +1214,4 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
|
||||
@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'))}}
|
||||
</button>
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
@ -1,11 +1,16 @@
|
||||
<form [formGroup]="form" *transloco="let t">
|
||||
|
||||
@for(item of Items; let i = $index; track item; let isFirst = $first) {
|
||||
<div formArrayName="items">
|
||||
@for(item of ItemsArray.controls; let i = $index; track i) {
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-lg-10 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
@ -13,12 +18,16 @@
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
|
@ -9,15 +9,14 @@ import {
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-list',
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslocoDirective],
|
||||
imports: [ReactiveFormsModule, TranslocoDirective],
|
||||
templateUrl: './edit-list.component.html',
|
||||
styleUrl: './edit-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -31,20 +30,16 @@ export class EditListComponent implements OnInit {
|
||||
@Input({required: true}) label = '';
|
||||
@Output() updateItems = new EventEmitter<Array<string>>();
|
||||
|
||||
form: FormGroup = new FormGroup({});
|
||||
private combinedItems: string = '';
|
||||
form: FormGroup = new FormGroup({items: new FormArray([])});
|
||||
|
||||
get Items() {
|
||||
return this.combinedItems.split(',') || [''];
|
||||
get ItemsArray(): FormArray {
|
||||
return this.form.get('items') as FormArray;
|
||||
}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.items.forEach((link, index) => {
|
||||
this.form.addControl('link' + index, new FormControl(link, []));
|
||||
});
|
||||
this.items.forEach(item => this.addItem(item));
|
||||
|
||||
this.combinedItems = this.items.join(',');
|
||||
|
||||
this.form.valueChanges.pipe(
|
||||
debounceTime(100),
|
||||
@ -55,47 +50,39 @@ export class EditListComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
createItemControl(value: string = ''): FormControl {
|
||||
return new FormControl(value, []);
|
||||
}
|
||||
|
||||
add() {
|
||||
this.combinedItems += ',';
|
||||
this.form.addControl('link' + (this.Items.length - 1), new FormControl('', []));
|
||||
this.ItemsArray.push(this.createItemControl());
|
||||
this.emit();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
addItem(value: string) {
|
||||
this.ItemsArray.push(this.createItemControl(value));
|
||||
}
|
||||
|
||||
remove(index: number) {
|
||||
|
||||
const initialControls = Object.keys(this.form.controls)
|
||||
.filter(key => key.startsWith('link'));
|
||||
|
||||
if (index == 0 && initialControls.length === 1) {
|
||||
this.form.get(initialControls[0])?.setValue('', {emitEvent: true});
|
||||
// If it's the last item, just clear its value
|
||||
if (this.ItemsArray.length === 1) {
|
||||
this.ItemsArray.at(0).setValue('');
|
||||
this.emit();
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the form control explicitly then rebuild the combinedItems
|
||||
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.ItemsArray.removeAt(index);
|
||||
this.emit();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
// Emit non-empty item values
|
||||
emit() {
|
||||
this.updateItems.emit(Object.keys(this.form.controls)
|
||||
.filter(key => key.startsWith('link'))
|
||||
.map(key => this.form.get(key)?.value)
|
||||
.filter(v => v !== null && v !== ''));
|
||||
const nonEmptyItems = this.ItemsArray.controls
|
||||
.map(control => control.value)
|
||||
.filter(value => value !== null && value.trim() !== '');
|
||||
|
||||
this.updateItems.emit(nonEmptyItems);
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +54,17 @@ export class ReadMoreComponent implements OnChanges {
|
||||
this.hideToggle = false;
|
||||
if (this.isCollapsed) {
|
||||
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 + '…';
|
||||
} else if (!this.isCollapsed) {
|
||||
this.currentText = text;
|
||||
@ -62,6 +72,7 @@ export class ReadMoreComponent implements OnChanges {
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.determineView();
|
||||
}
|
||||
|
@ -1,12 +1,4 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {
|
||||
NgbActiveModal,
|
||||
@ -244,12 +236,16 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
|
||||
this.madeChanges = false;
|
||||
|
||||
// TODO: Refactor into FormArray
|
||||
for(let fileTypeGroup of allFileTypeGroup) {
|
||||
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), []));
|
||||
}
|
||||
|
||||
// TODO: Refactor into FormArray
|
||||
for(let glob of this.library.excludePatterns) {
|
||||
this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, []));
|
||||
}
|
||||
|
||||
this.excludePatterns = this.library.excludePatterns;
|
||||
} else {
|
||||
for(let fileTypeGroup of allFileTypeGroup) {
|
||||
|
@ -264,6 +264,18 @@
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</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>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
@ -110,7 +110,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
get Locale() {
|
||||
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('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, []));
|
||||
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('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('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('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('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false});
|
||||
@ -265,6 +267,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
readerMode: parseInt(modelSettings.readerMode, 10),
|
||||
layoutMode: parseInt(modelSettings.layoutMode, 10),
|
||||
showScreenHints: modelSettings.showScreenHints,
|
||||
allowAutomaticWebtoonReaderDetection: modelSettings.allowAutomaticWebtoonReaderDetection,
|
||||
backgroundColor: modelSettings.backgroundColor || '#000',
|
||||
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
|
||||
bookReaderLineSpacing: modelSettings.bookReaderLineSpacing,
|
||||
|
@ -156,6 +156,8 @@
|
||||
"emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book",
|
||||
"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",
|
||||
"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",
|
||||
"tap-to-paginate-label": "Tap to Paginate",
|
||||
@ -973,9 +975,11 @@
|
||||
"more-alt": "More",
|
||||
"time-left-alt": "Time Left",
|
||||
"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-tooltip": "Publication Status"
|
||||
"publication-status-tooltip": "Publication Status",
|
||||
"on": "{{reader-settings.on}}",
|
||||
"off": "{{reader-settings.off}}"
|
||||
},
|
||||
|
||||
"match-series-modal": {
|
||||
@ -1241,7 +1245,8 @@
|
||||
"language-title": "{{edit-chapter-modal.language-label}}",
|
||||
"release-title": "{{sort-field-pipe.release-year}}",
|
||||
"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": {
|
||||
@ -1340,7 +1345,7 @@
|
||||
"reset": "{{common.reset}}",
|
||||
"test": "Test",
|
||||
"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 you’re 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 /",
|
||||
|
||||
"sender-address-label": "Sender Address",
|
||||
@ -1751,7 +1756,19 @@
|
||||
"read-options-alt": "Read options",
|
||||
"incognito-alt": "(Incognito)",
|
||||
"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": {
|
||||
@ -2617,7 +2634,8 @@
|
||||
"person-image-downloaded": "Person cover was downloaded and applied.",
|
||||
"bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?",
|
||||
"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": {
|
||||
|
@ -38,15 +38,55 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
background-color: var(--btn-secondary-outline-bg-color);
|
||||
border-color: var(--btn-secondary-outline-border-color);
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
--bs-btn-color: var(--btn-secondary-outline-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);
|
||||
--btn-secondary-outline-text-color: var(--btn-secondary-outline-hover-bg-color);
|
||||
|
||||
color: var(--btn-secondary-outline-hover-text-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 {
|
||||
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);
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
--bs-btn-disabled-bg: transparent;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
|
@ -146,9 +146,10 @@
|
||||
--btn-secondary-font-weight: bold;
|
||||
--btn-secondary-outline-text-color: white;
|
||||
--btn-secondary-outline-bg-color: transparent;
|
||||
--btn-secondary-outline-border-color: transparent;
|
||||
--btn-secondary-outline-hover-bg-color: transparent;
|
||||
--btn-secondary-outline-hover-border-color: transparent;
|
||||
--btn-secondary-outline-border-color: #6c757d;
|
||||
--btn-secondary-outline-hover-text-color: #fff;
|
||||
--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-primary-text-text-color: white;
|
||||
--btn-secondary-text-text-color: lightgrey;
|
||||
|
Loading…
x
Reference in New Issue
Block a user