mirror of
				https://github.com/Kareadita/Kavita.git
				synced 2025-10-31 10:37:04 -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 System.Linq; | ||||||
| using API.Entities; | using API.Entities; | ||||||
| using API.Helpers; | using API.Helpers; | ||||||
| @ -49,17 +50,14 @@ public class OrderableHelperTests | |||||||
|     [Fact] |     [Fact] | ||||||
|     public void ReorderItems_InvalidPosition_NoChange() |     public void ReorderItems_InvalidPosition_NoChange() | ||||||
|     { |     { | ||||||
|         // Arrange |  | ||||||
|         var items = new List<AppUserSideNavStream> |         var items = new List<AppUserSideNavStream> | ||||||
|         { |         { | ||||||
|             new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, |             new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, | ||||||
|             new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, |             new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         // Act |  | ||||||
|         OrderableHelper.ReorderItems(items, 2, 3);  // Position 3 is out of range |         OrderableHelper.ReorderItems(items, 2, 3);  // Position 3 is out of range | ||||||
| 
 | 
 | ||||||
|         // Assert |  | ||||||
|         Assert.Equal(1, items[0].Id);  // Item 1 should remain at position 0 |         Assert.Equal(1, items[0].Id);  // Item 1 should remain at position 0 | ||||||
|         Assert.Equal(2, items[1].Id);  // Item 2 should remain at position 1 |         Assert.Equal(2, items[1].Id);  // Item 2 should remain at position 1 | ||||||
|     } |     } | ||||||
| @ -80,7 +78,6 @@ public class OrderableHelperTests | |||||||
|     [Fact] |     [Fact] | ||||||
|     public void ReorderItems_DoubleMove() |     public void ReorderItems_DoubleMove() | ||||||
|     { |     { | ||||||
|         // Arrange |  | ||||||
|         var items = new List<AppUserSideNavStream> |         var items = new List<AppUserSideNavStream> | ||||||
|         { |         { | ||||||
|             new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, |             new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, | ||||||
| @ -94,7 +91,6 @@ public class OrderableHelperTests | |||||||
|         // Move 4 -> 1 |         // Move 4 -> 1 | ||||||
|         OrderableHelper.ReorderItems(items, 5, 1); |         OrderableHelper.ReorderItems(items, 5, 1); | ||||||
| 
 | 
 | ||||||
|         // Assert |  | ||||||
|         Assert.Equal(1, items[0].Id); |         Assert.Equal(1, items[0].Id); | ||||||
|         Assert.Equal(0, items[0].Order); |         Assert.Equal(0, items[0].Order); | ||||||
|         Assert.Equal(5, items[1].Id); |         Assert.Equal(5, items[1].Id); | ||||||
| @ -109,4 +105,98 @@ public class OrderableHelperTests | |||||||
| 
 | 
 | ||||||
|         Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); |         Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     private static List<ReadingListItem> CreateTestReadingListItems(int count = 4) | ||||||
|  |     { | ||||||
|  |         var items = new List<ReadingListItem>(); | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < count; i++) | ||||||
|  |         { | ||||||
|  |             items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1}); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return items; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [Fact] | ||||||
|  |     public void ReorderItems_MoveItemToBeginning_CorrectOrder() | ||||||
|  |     { | ||||||
|  |         var items = CreateTestReadingListItems(); | ||||||
|  | 
 | ||||||
|  |         OrderableHelper.ReorderItems(items, 3, 0); | ||||||
|  | 
 | ||||||
|  |         Assert.Equal(3, items[0].Id); | ||||||
|  |         Assert.Equal(1, items[1].Id); | ||||||
|  |         Assert.Equal(2, items[2].Id); | ||||||
|  |         Assert.Equal(4, items[3].Id); | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < items.Count; i++) | ||||||
|  |         { | ||||||
|  |             Assert.Equal(i, items[i].Order); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [Fact] | ||||||
|  |     public void ReorderItems_MoveItemToEnd_CorrectOrder() | ||||||
|  |     { | ||||||
|  |         var items = CreateTestReadingListItems(); | ||||||
|  | 
 | ||||||
|  |         OrderableHelper.ReorderItems(items, 1, 3); | ||||||
|  | 
 | ||||||
|  |         Assert.Equal(2, items[0].Id); | ||||||
|  |         Assert.Equal(3, items[1].Id); | ||||||
|  |         Assert.Equal(4, items[2].Id); | ||||||
|  |         Assert.Equal(1, items[3].Id); | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < items.Count; i++) | ||||||
|  |         { | ||||||
|  |             Assert.Equal(i, items[i].Order); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [Fact] | ||||||
|  |     public void ReorderItems_MoveItemToMiddle_CorrectOrder() | ||||||
|  |     { | ||||||
|  |         var items = CreateTestReadingListItems(); | ||||||
|  | 
 | ||||||
|  |         OrderableHelper.ReorderItems(items, 4, 2); | ||||||
|  | 
 | ||||||
|  |         Assert.Equal(1, items[0].Id); | ||||||
|  |         Assert.Equal(2, items[1].Id); | ||||||
|  |         Assert.Equal(4, items[2].Id); | ||||||
|  |         Assert.Equal(3, items[3].Id); | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < items.Count; i++) | ||||||
|  |         { | ||||||
|  |             Assert.Equal(i, items[i].Order); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [Fact] | ||||||
|  |     public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd() | ||||||
|  |     { | ||||||
|  |         var items = CreateTestReadingListItems(); | ||||||
|  | 
 | ||||||
|  |         OrderableHelper.ReorderItems(items, 2, 10); | ||||||
|  | 
 | ||||||
|  |         Assert.Equal(1, items[0].Id); | ||||||
|  |         Assert.Equal(3, items[1].Id); | ||||||
|  |         Assert.Equal(4, items[2].Id); | ||||||
|  |         Assert.Equal(2, items[3].Id); | ||||||
|  | 
 | ||||||
|  |         for (var i = 0; i < items.Count; i++) | ||||||
|  |         { | ||||||
|  |             Assert.Equal(i, items[i].Order); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [Fact] | ||||||
|  |     public void ReorderItems_NegativePosition_ThrowsArgumentException() | ||||||
|  |     { | ||||||
|  |         var items = CreateTestReadingListItems(); | ||||||
|  | 
 | ||||||
|  |         Assert.Throws<ArgumentException>(() => | ||||||
|  |             OrderableHelper.ReorderItems(items, 2, -1) | ||||||
|  |         ); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -10,6 +10,10 @@ public class StringHelperTests | |||||||
|         "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> <br><br><br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>", |         "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> <br><br><br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>", | ||||||
|         "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>" |         "<p>A Perfect Marriage Becomes a Perfect Affair!<br /> Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?</p>" | ||||||
|     )] |     )] | ||||||
|  |     [InlineData( | ||||||
|  |         "<p><a href=\"https://blog.goo.ne.jp/tamakiya_web\">Blog</a> | <a href=\"https://twitter.com/tamakinozomu\">Twitter</a> | <a href=\"https://www.pixiv.net/member.php?id=68961\">Pixiv</a> | <a href=\"https://pawoo.net/&#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) |     public void TestSquashBreaklines(string input, string expected) | ||||||
|     { |     { | ||||||
|         Assert.Equal(expected, StringHelper.SquashBreaklines(input)); |         Assert.Equal(expected, StringHelper.SquashBreaklines(input)); | ||||||
| @ -28,4 +32,15 @@ public class StringHelperTests | |||||||
|     { |     { | ||||||
|         Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); |         Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     [Theory] | ||||||
|  |     [InlineData( | ||||||
|  | """<a href=\"https://pawoo.net/&#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); |         var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); | ||||||
|         await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); |         await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); | ||||||
|         _logger.LogDebug("Caching libraries for {Key}", cacheKey); |  | ||||||
| 
 | 
 | ||||||
|         return Ok(ret); |         return Ok(ret); | ||||||
|     } |     } | ||||||
| @ -419,8 +418,7 @@ public class LibraryController : BaseApiController | |||||||
|             .Distinct() |             .Distinct() | ||||||
|             .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); |             .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); | ||||||
| 
 | 
 | ||||||
|         var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, |         var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); | ||||||
|             new List<string>() {dto.FolderPath}); |  | ||||||
| 
 | 
 | ||||||
|         _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); |         _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -46,8 +46,8 @@ public class LocaleController : BaseApiController | |||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); |         var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); | ||||||
|         await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(7)); |         await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1)); | ||||||
| 
 | 
 | ||||||
|         return Ok(); |         return Ok(ret); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -803,7 +803,7 @@ public class ReaderController : BaseApiController | |||||||
|     /// <param name="seriesId"></param> |     /// <param name="seriesId"></param> | ||||||
|     /// <returns></returns> |     /// <returns></returns> | ||||||
|     [HttpGet("time-left")] |     [HttpGet("time-left")] | ||||||
|     [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId"])] |     [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])] | ||||||
|     public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId) |     public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId) | ||||||
|     { |     { | ||||||
|         var userId = User.GetUserId(); |         var userId = User.GetUserId(); | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ using API.Data; | |||||||
| using API.Data.Repositories; | using API.Data.Repositories; | ||||||
| using API.DTOs; | using API.DTOs; | ||||||
| using API.DTOs.ReadingLists; | using API.DTOs.ReadingLists; | ||||||
|  | using API.Entities.Enums; | ||||||
| using API.Extensions; | using API.Extensions; | ||||||
| using API.Helpers; | using API.Helpers; | ||||||
| using API.Services; | using API.Services; | ||||||
| @ -23,13 +24,15 @@ public class ReadingListController : BaseApiController | |||||||
|     private readonly IUnitOfWork _unitOfWork; |     private readonly IUnitOfWork _unitOfWork; | ||||||
|     private readonly IReadingListService _readingListService; |     private readonly IReadingListService _readingListService; | ||||||
|     private readonly ILocalizationService _localizationService; |     private readonly ILocalizationService _localizationService; | ||||||
|  |     private readonly IReaderService _readerService; | ||||||
| 
 | 
 | ||||||
|     public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, |     public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, | ||||||
|         ILocalizationService localizationService) |         ILocalizationService localizationService, IReaderService readerService) | ||||||
|     { |     { | ||||||
|         _unitOfWork = unitOfWork; |         _unitOfWork = unitOfWork; | ||||||
|         _readingListService = readingListService; |         _readingListService = readingListService; | ||||||
|         _localizationService = localizationService; |         _localizationService = localizationService; | ||||||
|  |         _readerService = readerService; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
| @ -128,7 +131,7 @@ public class ReadingListController : BaseApiController | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Deletes a list item from the list. Will reorder all item positions afterwards |     /// Deletes a list item from the list. Item orders will update as a result. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     /// <param name="dto"></param> |     /// <param name="dto"></param> | ||||||
|     /// <returns></returns> |     /// <returns></returns> | ||||||
| @ -452,26 +455,38 @@ public class ReadingListController : BaseApiController | |||||||
|         return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); |         return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Returns a list of characters associated with the reading list |     /// Returns a list of a given role associated with the reading list | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="readingListId"></param> | ||||||
|  |     /// <param name="role">PersonRole</param> | ||||||
|  |     /// <returns></returns> | ||||||
|  |     [HttpGet("people")] | ||||||
|  |     [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])] | ||||||
|  |     public ActionResult<IEnumerable<PersonDto>> GetPeopleByRoleForList(int readingListId, PersonRole role) | ||||||
|  |     { | ||||||
|  |         return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role)); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Returns all people in given roles for a reading list | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     /// <param name="readingListId"></param> |     /// <param name="readingListId"></param> | ||||||
|     /// <returns></returns> |     /// <returns></returns> | ||||||
|     [HttpGet("characters")] |     [HttpGet("all-people")] | ||||||
|     [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] |     [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])] | ||||||
|     public ActionResult<IEnumerable<PersonDto>> GetCharactersForList(int readingListId) |     public async Task<ActionResult<IEnumerable<PersonDto>>> GetAllPeopleForList(int readingListId) | ||||||
|     { |     { | ||||||
|         return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); |         return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Returns the next chapter within the reading list |     /// Returns the next chapter within the reading list | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     /// <param name="currentChapterId"></param> |     /// <param name="currentChapterId"></param> | ||||||
|     /// <param name="readingListId"></param> |     /// <param name="readingListId"></param> | ||||||
|     /// <returns>Chapter Id for next item, -1 if nothing exists</returns> |     /// <returns>Chapter ID for next item, -1 if nothing exists</returns> | ||||||
|     [HttpGet("next-chapter")] |     [HttpGet("next-chapter")] | ||||||
|     public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId) |     public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId) | ||||||
|     { |     { | ||||||
| @ -577,4 +592,26 @@ public class ReadingListController : BaseApiController | |||||||
| 
 | 
 | ||||||
|         return Ok(); |         return Ok(); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Returns random information about a Reading List | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="readingListId"></param> | ||||||
|  |     /// <returns></returns> | ||||||
|  |     [HttpGet("info")] | ||||||
|  |     [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])] | ||||||
|  |     public async Task<ActionResult<ReadingListInfoDto?>> GetReadingListInfo(int readingListId) | ||||||
|  |     { | ||||||
|  |         var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId); | ||||||
|  | 
 | ||||||
|  |         if (result == null) return Ok(null); | ||||||
|  | 
 | ||||||
|  |         var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub); | ||||||
|  | 
 | ||||||
|  |         result.MinHoursToRead = timeEstimate.MinHours; | ||||||
|  |         result.AvgHoursToRead = timeEstimate.AvgHours; | ||||||
|  |         result.MaxHoursToRead = timeEstimate.MaxHours; | ||||||
|  | 
 | ||||||
|  |         return Ok(result); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -238,7 +238,8 @@ public class SeriesController : BaseApiController | |||||||
|             // Trigger a refresh when we are moving from a locked image to a non-locked |             // Trigger a refresh when we are moving from a locked image to a non-locked | ||||||
|             needsRefreshMetadata = true; |             needsRefreshMetadata = true; | ||||||
|             series.CoverImage = null; |             series.CoverImage = null; | ||||||
|             series.CoverImageLocked = updateSeries.CoverImageLocked; |             series.CoverImageLocked = false; | ||||||
|  |             _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); | ||||||
|             series.ResetColorScape(); |             series.ResetColorScape(); | ||||||
| 
 | 
 | ||||||
|         } |         } | ||||||
|  | |||||||
							
								
								
									
										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 System; | ||||||
|  | using API.Entities.Enums; | ||||||
| using API.Entities.Interfaces; | using API.Entities.Interfaces; | ||||||
| 
 | 
 | ||||||
| namespace API.DTOs.ReadingLists; | namespace API.DTOs.ReadingLists; | ||||||
| @ -43,6 +44,10 @@ public class ReadingListDto : IHasCoverImage | |||||||
|     /// Maximum Month the Reading List starts |     /// Maximum Month the Reading List starts | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public int EndingMonth { get; set; } |     public int EndingMonth { get; set; } | ||||||
|  |     /// <summary> | ||||||
|  |     /// The highest age rating from all Series within the reading list | ||||||
|  |     /// </summary> | ||||||
|  |     public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; | ||||||
| 
 | 
 | ||||||
|     public void ResetColorScape() |     public void ResetColorScape() | ||||||
|     { |     { | ||||||
|  | |||||||
							
								
								
									
										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> |     /// <summary> | ||||||
|     /// Release Date from Chapter |     /// Release Date from Chapter | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public DateTime ReleaseDate { get; set; } |     public DateTime? ReleaseDate { get; set; } | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Used internally only |     /// Used internally only | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @ -33,7 +33,7 @@ public class ReadingListItemDto | |||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// The last time a reading list item (underlying chapter) was read by current authenticated user |     /// The last time a reading list item (underlying chapter) was read by current authenticated user | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public DateTime LastReadingProgressUtc { get; set; } |     public DateTime? LastReadingProgressUtc { get; set; } | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// File size of underlying item |     /// File size of underlying item | ||||||
|     /// </summary> |     /// </summary> | ||||||
|  | |||||||
| @ -63,6 +63,13 @@ public class UserPreferencesDto | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     [Required] |     [Required] | ||||||
|     public bool ShowScreenHints { get; set; } = true; |     public bool ShowScreenHints { get; set; } = true; | ||||||
|  |     /// <summary> | ||||||
|  |     /// Manga Reader Option: Allow Automatic Webtoon detection | ||||||
|  |     /// </summary> | ||||||
|  |     [Required] | ||||||
|  |     public bool AllowAutomaticWebtoonReaderDetection { get; set; } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Book Reader Option: Override extra Margin |     /// Book Reader Option: Override extra Margin | ||||||
|     /// </summary> |     /// </summary> | ||||||
|  | |||||||
| @ -133,6 +133,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int, | |||||||
|         builder.Entity<AppUserPreferences>() |         builder.Entity<AppUserPreferences>() | ||||||
|             .Property(b => b.WantToReadSync) |             .Property(b => b.WantToReadSync) | ||||||
|             .HasDefaultValue(true); |             .HasDefaultValue(true); | ||||||
|  |         builder.Entity<AppUserPreferences>() | ||||||
|  |             .Property(b => b.AllowAutomaticWebtoonReaderDetection) | ||||||
|  |             .HasDefaultValue(true); | ||||||
| 
 | 
 | ||||||
|         builder.Entity<Library>() |         builder.Entity<Library>() | ||||||
|             .Property(b => b.AllowScrobbling) |             .Property(b => b.AllowScrobbling) | ||||||
|  | |||||||
							
								
								
									
										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) |         protected override void BuildModel(ModelBuilder modelBuilder) | ||||||
|         { |         { | ||||||
| #pragma warning disable 612, 618 | #pragma warning disable 612, 618 | ||||||
|             modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); |             modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.AppRole", b => |             modelBuilder.Entity("API.Entities.AppRole", b => | ||||||
|                 { |                 { | ||||||
| @ -353,6 +353,11 @@ namespace API.Data.Migrations | |||||||
|                         .ValueGeneratedOnAdd() |                         .ValueGeneratedOnAdd() | ||||||
|                         .HasColumnType("INTEGER"); |                         .HasColumnType("INTEGER"); | ||||||
| 
 | 
 | ||||||
|  |                     b.Property<bool>("AllowAutomaticWebtoonReaderDetection") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("INTEGER") | ||||||
|  |                         .HasDefaultValue(true); | ||||||
|  | 
 | ||||||
|                     b.Property<bool>("AniListScrobblingEnabled") |                     b.Property<bool>("AniListScrobblingEnabled") | ||||||
|                         .ValueGeneratedOnAdd() |                         .ValueGeneratedOnAdd() | ||||||
|                         .HasColumnType("INTEGER") |                         .HasColumnType("INTEGER") | ||||||
| @ -911,24 +916,6 @@ namespace API.Data.Migrations | |||||||
|                     b.ToTable("Chapter"); |                     b.ToTable("Chapter"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.ChapterPeople", b => |  | ||||||
|                 { |  | ||||||
|                     b.Property<int>("ChapterId") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<int>("PersonId") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<int>("Role") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.HasKey("ChapterId", "PersonId", "Role"); |  | ||||||
| 
 |  | ||||||
|                     b.HasIndex("PersonId"); |  | ||||||
| 
 |  | ||||||
|                     b.ToTable("ChapterPeople"); |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|             modelBuilder.Entity("API.Entities.CollectionTag", b => |             modelBuilder.Entity("API.Entities.CollectionTag", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<int>("Id") |                     b.Property<int>("Id") | ||||||
| @ -1640,7 +1627,7 @@ namespace API.Data.Migrations | |||||||
|                     b.ToTable("MetadataFieldMapping"); |                     b.ToTable("MetadataFieldMapping"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.MetadataSettings", b => |             modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<int>("Id") |                     b.Property<int>("Id") | ||||||
|                         .ValueGeneratedOnAdd() |                         .ValueGeneratedOnAdd() | ||||||
| @ -1703,7 +1690,25 @@ namespace API.Data.Migrations | |||||||
|                     b.ToTable("MetadataSettings"); |                     b.ToTable("MetadataSettings"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.Person", b => |             modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<int>("ChapterId") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<int>("PersonId") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<int>("Role") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.HasKey("ChapterId", "PersonId", "Role"); | ||||||
|  | 
 | ||||||
|  |                     b.HasIndex("PersonId"); | ||||||
|  | 
 | ||||||
|  |                     b.ToTable("ChapterPeople"); | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|  |             modelBuilder.Entity("API.Entities.Person.Person", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<int>("Id") |                     b.Property<int>("Id") | ||||||
|                         .ValueGeneratedOnAdd() |                         .ValueGeneratedOnAdd() | ||||||
| @ -1747,6 +1752,32 @@ namespace API.Data.Migrations | |||||||
|                     b.ToTable("Person"); |                     b.ToTable("Person"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|  |             modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<int>("SeriesMetadataId") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<int>("PersonId") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<int>("Role") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<bool>("KavitaPlusConnection") | ||||||
|  |                         .HasColumnType("INTEGER"); | ||||||
|  | 
 | ||||||
|  |                     b.Property<int>("OrderWeight") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("INTEGER") | ||||||
|  |                         .HasDefaultValue(0); | ||||||
|  | 
 | ||||||
|  |                     b.HasKey("SeriesMetadataId", "PersonId", "Role"); | ||||||
|  | 
 | ||||||
|  |                     b.HasIndex("PersonId"); | ||||||
|  | 
 | ||||||
|  |                     b.ToTable("SeriesMetadataPeople"); | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|             modelBuilder.Entity("API.Entities.ReadingList", b => |             modelBuilder.Entity("API.Entities.ReadingList", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<int>("Id") |                     b.Property<int>("Id") | ||||||
| @ -2111,32 +2142,6 @@ namespace API.Data.Migrations | |||||||
|                     b.ToTable("Series"); |                     b.ToTable("Series"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => |  | ||||||
|                 { |  | ||||||
|                     b.Property<int>("SeriesMetadataId") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<int>("PersonId") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<int>("Role") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<bool>("KavitaPlusConnection") |  | ||||||
|                         .HasColumnType("INTEGER"); |  | ||||||
| 
 |  | ||||||
|                     b.Property<int>("OrderWeight") |  | ||||||
|                         .ValueGeneratedOnAdd() |  | ||||||
|                         .HasColumnType("INTEGER") |  | ||||||
|                         .HasDefaultValue(0); |  | ||||||
| 
 |  | ||||||
|                     b.HasKey("SeriesMetadataId", "PersonId", "Role"); |  | ||||||
| 
 |  | ||||||
|                     b.HasIndex("PersonId"); |  | ||||||
| 
 |  | ||||||
|                     b.ToTable("SeriesMetadataPeople"); |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|             modelBuilder.Entity("API.Entities.ServerSetting", b => |             modelBuilder.Entity("API.Entities.ServerSetting", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<int>("Key") |                     b.Property<int>("Key") | ||||||
| @ -2804,25 +2809,6 @@ namespace API.Data.Migrations | |||||||
|                     b.Navigation("Volume"); |                     b.Navigation("Volume"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.ChapterPeople", b => |  | ||||||
|                 { |  | ||||||
|                     b.HasOne("API.Entities.Chapter", "Chapter") |  | ||||||
|                         .WithMany("People") |  | ||||||
|                         .HasForeignKey("ChapterId") |  | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |  | ||||||
|                         .IsRequired(); |  | ||||||
| 
 |  | ||||||
|                     b.HasOne("API.Entities.Person", "Person") |  | ||||||
|                         .WithMany("ChapterPeople") |  | ||||||
|                         .HasForeignKey("PersonId") |  | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |  | ||||||
|                         .IsRequired(); |  | ||||||
| 
 |  | ||||||
|                     b.Navigation("Chapter"); |  | ||||||
| 
 |  | ||||||
|                     b.Navigation("Person"); |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|             modelBuilder.Entity("API.Entities.Device", b => |             modelBuilder.Entity("API.Entities.Device", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("API.Entities.AppUser", "AppUser") |                     b.HasOne("API.Entities.AppUser", "AppUser") | ||||||
| @ -2943,7 +2929,7 @@ namespace API.Data.Migrations | |||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => |             modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") |                     b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") | ||||||
|                         .WithMany("FieldMappings") |                         .WithMany("FieldMappings") | ||||||
|                         .HasForeignKey("MetadataSettingsId") |                         .HasForeignKey("MetadataSettingsId") | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
| @ -2952,6 +2938,44 @@ namespace API.Data.Migrations | |||||||
|                     b.Navigation("MetadataSettings"); |                     b.Navigation("MetadataSettings"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|  |             modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("API.Entities.Chapter", "Chapter") | ||||||
|  |                         .WithMany("People") | ||||||
|  |                         .HasForeignKey("ChapterId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired(); | ||||||
|  | 
 | ||||||
|  |                     b.HasOne("API.Entities.Person.Person", "Person") | ||||||
|  |                         .WithMany("ChapterPeople") | ||||||
|  |                         .HasForeignKey("PersonId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired(); | ||||||
|  | 
 | ||||||
|  |                     b.Navigation("Chapter"); | ||||||
|  | 
 | ||||||
|  |                     b.Navigation("Person"); | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|  |             modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("API.Entities.Person.Person", "Person") | ||||||
|  |                         .WithMany("SeriesMetadataPeople") | ||||||
|  |                         .HasForeignKey("PersonId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired(); | ||||||
|  | 
 | ||||||
|  |                     b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") | ||||||
|  |                         .WithMany("People") | ||||||
|  |                         .HasForeignKey("SeriesMetadataId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired(); | ||||||
|  | 
 | ||||||
|  |                     b.Navigation("Person"); | ||||||
|  | 
 | ||||||
|  |                     b.Navigation("SeriesMetadata"); | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|             modelBuilder.Entity("API.Entities.ReadingList", b => |             modelBuilder.Entity("API.Entities.ReadingList", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("API.Entities.AppUser", "AppUser") |                     b.HasOne("API.Entities.AppUser", "AppUser") | ||||||
| @ -3072,25 +3096,6 @@ namespace API.Data.Migrations | |||||||
|                     b.Navigation("Library"); |                     b.Navigation("Library"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => |  | ||||||
|                 { |  | ||||||
|                     b.HasOne("API.Entities.Person", "Person") |  | ||||||
|                         .WithMany("SeriesMetadataPeople") |  | ||||||
|                         .HasForeignKey("PersonId") |  | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |  | ||||||
|                         .IsRequired(); |  | ||||||
| 
 |  | ||||||
|                     b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") |  | ||||||
|                         .WithMany("People") |  | ||||||
|                         .HasForeignKey("SeriesMetadataId") |  | ||||||
|                         .OnDelete(DeleteBehavior.Cascade) |  | ||||||
|                         .IsRequired(); |  | ||||||
| 
 |  | ||||||
|                     b.Navigation("Person"); |  | ||||||
| 
 |  | ||||||
|                     b.Navigation("SeriesMetadata"); |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|             modelBuilder.Entity("API.Entities.Volume", b => |             modelBuilder.Entity("API.Entities.Volume", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("API.Entities.Series", "Series") |                     b.HasOne("API.Entities.Series", "Series") | ||||||
| @ -3351,12 +3356,12 @@ namespace API.Data.Migrations | |||||||
|                     b.Navigation("People"); |                     b.Navigation("People"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.MetadataSettings", b => |             modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => | ||||||
|                 { |                 { | ||||||
|                     b.Navigation("FieldMappings"); |                     b.Navigation("FieldMappings"); | ||||||
|                 }); |                 }); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("API.Entities.Person", b => |             modelBuilder.Entity("API.Entities.Person.Person", b => | ||||||
|                 { |                 { | ||||||
|                     b.Navigation("ChapterPeople"); |                     b.Navigation("ChapterPeople"); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,10 +1,7 @@ | |||||||
| using System.Collections; |  | ||||||
| using System; |  | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using API.DTOs; | using API.DTOs; | ||||||
| using API.Entities; |  | ||||||
| using API.Entities.Enums; | using API.Entities.Enums; | ||||||
| using API.Entities.Person; | using API.Entities.Person; | ||||||
| using API.Extensions; | using API.Extensions; | ||||||
| @ -31,15 +28,13 @@ public interface IPersonRepository | |||||||
|     Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); |     Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); | ||||||
|     Task RemoveAllPeopleNoLongerAssociated(); |     Task RemoveAllPeopleNoLongerAssociated(); | ||||||
|     Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null); |     Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null); | ||||||
|     Task<int> GetCountAsync(); |  | ||||||
| 
 | 
 | ||||||
|     Task<string> GetCoverImageAsync(int personId); |     Task<string?> GetCoverImageAsync(int personId); | ||||||
|     Task<string?> GetCoverImageByNameAsync(string name); |     Task<string?> GetCoverImageByNameAsync(string name); | ||||||
|     Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId); |     Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId); | ||||||
|     Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams); |     Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams); | ||||||
|     Task<Person?> GetPersonById(int personId); |     Task<Person?> GetPersonById(int personId); | ||||||
|     Task<PersonDto?> GetPersonDtoByName(string name, int userId); |     Task<PersonDto?> GetPersonDtoByName(string name, int userId); | ||||||
|     Task<Person> GetPersonByName(string name); |  | ||||||
|     Task<bool> IsNameUnique(string name); |     Task<bool> IsNameUnique(string name); | ||||||
| 
 | 
 | ||||||
|     Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId); |     Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId); | ||||||
| @ -126,12 +121,8 @@ public class PersonRepository : IPersonRepository | |||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<int> GetCountAsync() |  | ||||||
|     { |  | ||||||
|         return await _context.Person.CountAsync(); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     public async Task<string> GetCoverImageAsync(int personId) |     public async Task<string?> GetCoverImageAsync(int personId) | ||||||
|     { |     { | ||||||
|         return await _context.Person |         return await _context.Person | ||||||
|             .Where(c => c.Id == personId) |             .Where(c => c.Id == personId) | ||||||
| @ -139,7 +130,7 @@ public class PersonRepository : IPersonRepository | |||||||
|             .SingleOrDefaultAsync(); |             .SingleOrDefaultAsync(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<string> GetCoverImageByNameAsync(string name) |     public async Task<string?> GetCoverImageByNameAsync(string name) | ||||||
|     { |     { | ||||||
|         var normalized = name.ToNormalized(); |         var normalized = name.ToNormalized(); | ||||||
|         return await _context.Person |         return await _context.Person | ||||||
| @ -208,7 +199,7 @@ public class PersonRepository : IPersonRepository | |||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<PersonDto> GetPersonDtoByName(string name, int userId) |     public async Task<PersonDto?> GetPersonDtoByName(string name, int userId) | ||||||
|     { |     { | ||||||
|         var normalized = name.ToNormalized(); |         var normalized = name.ToNormalized(); | ||||||
|         var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); |         var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); | ||||||
| @ -220,11 +211,6 @@ public class PersonRepository : IPersonRepository | |||||||
|             .FirstOrDefaultAsync(); |             .FirstOrDefaultAsync(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<Person> GetPersonByName(string name) |  | ||||||
|     { |  | ||||||
|         return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     public async Task<bool> IsNameUnique(string name) |     public async Task<bool> IsNameUnique(string name) | ||||||
|     { |     { | ||||||
|         return !(await _context.Person.AnyAsync(p => p.Name == name)); |         return !(await _context.Person.AnyAsync(p => p.Name == name)); | ||||||
|  | |||||||
| @ -49,12 +49,14 @@ public interface IReadingListRepository | |||||||
|     Task<IList<string>> GetRandomCoverImagesAsync(int readingListId); |     Task<IList<string>> GetRandomCoverImagesAsync(int readingListId); | ||||||
|     Task<IList<string>> GetAllCoverImagesAsync(); |     Task<IList<string>> GetAllCoverImagesAsync(); | ||||||
|     Task<bool> ReadingListExists(string name); |     Task<bool> ReadingListExists(string name); | ||||||
|     IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId); |     IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role); | ||||||
|  |     Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId); | ||||||
|     Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); |     Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); | ||||||
|     Task<int> RemoveReadingListsWithoutSeries(); |     Task<int> RemoveReadingListsWithoutSeries(); | ||||||
|     Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); |     Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); | ||||||
|     Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items); |     Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items); | ||||||
|     Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); |     Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); | ||||||
|  |     Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| public class ReadingListRepository : IReadingListRepository | public class ReadingListRepository : IReadingListRepository | ||||||
| @ -121,12 +123,12 @@ public class ReadingListRepository : IReadingListRepository | |||||||
|             .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); |             .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId) |     public IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role) | ||||||
|     { |     { | ||||||
|         return _context.ReadingListItem |         return _context.ReadingListItem | ||||||
|             .Where(item => item.ReadingListId == readingListId) |             .Where(item => item.ReadingListId == readingListId) | ||||||
|             .SelectMany(item => item.Chapter.People) |             .SelectMany(item => item.Chapter.People) | ||||||
|             .Where(p => p.Role == PersonRole.Character) |             .Where(p => p.Role == role) | ||||||
|             .OrderBy(p => p.Person.NormalizedName) |             .OrderBy(p => p.Person.NormalizedName) | ||||||
|             .Select(p => p.Person) |             .Select(p => p.Person) | ||||||
|             .Distinct() |             .Distinct() | ||||||
| @ -134,6 +136,77 @@ public class ReadingListRepository : IReadingListRepository | |||||||
|             .AsEnumerable(); |             .AsEnumerable(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     public async Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId) | ||||||
|  |     { | ||||||
|  |         var allPeople = await _context.ReadingListItem | ||||||
|  |             .Where(item => item.ReadingListId == readingListId) | ||||||
|  |             .SelectMany(item => item.Chapter.People) | ||||||
|  |             .OrderBy(p => p.Person.NormalizedName) | ||||||
|  |             .Select(p => new | ||||||
|  |             { | ||||||
|  |                 Role = p.Role, | ||||||
|  |                 Person = _mapper.Map<PersonDto>(p.Person) | ||||||
|  |             }) | ||||||
|  |             .Distinct() | ||||||
|  |             .ToListAsync(); | ||||||
|  | 
 | ||||||
|  |         // Create the ReadingListCast object | ||||||
|  |         var cast = new ReadingListCast(); | ||||||
|  | 
 | ||||||
|  |         // Group people by role and populate the appropriate collections | ||||||
|  |         foreach (var personGroup in allPeople.GroupBy(p => p.Role)) | ||||||
|  |         { | ||||||
|  |             var people = personGroup.Select(pg => pg.Person).ToList(); | ||||||
|  | 
 | ||||||
|  |             switch (personGroup.Key) | ||||||
|  |             { | ||||||
|  |                 case PersonRole.Writer: | ||||||
|  |                     cast.Writers = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.CoverArtist: | ||||||
|  |                     cast.CoverArtists = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Publisher: | ||||||
|  |                     cast.Publishers = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Character: | ||||||
|  |                     cast.Characters = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Penciller: | ||||||
|  |                     cast.Pencillers = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Inker: | ||||||
|  |                     cast.Inkers = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Imprint: | ||||||
|  |                     cast.Imprints = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Colorist: | ||||||
|  |                     cast.Colorists = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Letterer: | ||||||
|  |                     cast.Letterers = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Editor: | ||||||
|  |                     cast.Editors = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Translator: | ||||||
|  |                     cast.Translators = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Team: | ||||||
|  |                     cast.Teams = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Location: | ||||||
|  |                     cast.Locations = people; | ||||||
|  |                     break; | ||||||
|  |                 case PersonRole.Other: | ||||||
|  |                     break; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return cast; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) |     public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) | ||||||
|     { |     { | ||||||
|         var extension = encodeFormat.GetExtension(); |         var extension = encodeFormat.GetExtension(); | ||||||
| @ -181,6 +254,33 @@ public class ReadingListRepository : IReadingListRepository | |||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="readingListId"></param> | ||||||
|  |     /// <returns></returns> | ||||||
|  |     public async Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId) | ||||||
|  |     { | ||||||
|  |         // Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub) | ||||||
|  |         var readingList = await _context.ReadingList | ||||||
|  |             .Where(rl => rl.Id == readingListId) | ||||||
|  |             .Include(rl => rl.Items) | ||||||
|  |             .ThenInclude(item => item.Series) | ||||||
|  |             .Include(rl => rl.Items) | ||||||
|  |             .ThenInclude(item => item.Volume) | ||||||
|  |             .Include(rl => rl.Items) | ||||||
|  |             .ThenInclude(item => item.Chapter) | ||||||
|  |             .Select(rl => new ReadingListInfoDto() | ||||||
|  |             { | ||||||
|  |                 WordCount = rl.Items.Sum(item => item.Chapter.WordCount), | ||||||
|  |                 Pages = rl.Items.Sum(item => item.Chapter.Pages), | ||||||
|  |                 IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub), | ||||||
|  |             }) | ||||||
|  |             .FirstOrDefaultAsync(); | ||||||
|  | 
 | ||||||
|  |         return readingList; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|     public void Remove(ReadingListItem item) |     public void Remove(ReadingListItem item) | ||||||
|     { |     { | ||||||
|  | |||||||
| @ -167,11 +167,11 @@ public class ScrobbleRepository : IScrobbleRepository | |||||||
|         var query =  _context.ScrobbleEvent |         var query =  _context.ScrobbleEvent | ||||||
|             .Where(e => e.AppUserId == userId) |             .Where(e => e.AppUserId == userId) | ||||||
|             .Include(e => e.Series) |             .Include(e => e.Series) | ||||||
|             .SortBy(filter.Field, filter.IsDescending) |  | ||||||
|             .WhereIf(!string.IsNullOrEmpty(filter.Query), s => |             .WhereIf(!string.IsNullOrEmpty(filter.Query), s => | ||||||
|                 EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") |                 EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") | ||||||
|             ) |             ) | ||||||
|             .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) |             .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) | ||||||
|  |             .SortBy(filter.Field, filter.IsDescending) | ||||||
|             .AsSplitQuery() |             .AsSplitQuery() | ||||||
|             .ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider); |             .ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -54,6 +54,10 @@ public class AppUserPreferences | |||||||
|     /// Manga Reader Option: Should swiping trigger pagination |     /// Manga Reader Option: Should swiping trigger pagination | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     public bool SwipeToPaginate { get; set; } |     public bool SwipeToPaginate { get; set; } | ||||||
|  |     /// <summary> | ||||||
|  |     /// Manga Reader Option: Allow Automatic Webtoon detection | ||||||
|  |     /// </summary> | ||||||
|  |     public bool AllowAutomaticWebtoonReaderDetection { get; set; } | ||||||
| 
 | 
 | ||||||
|     #endregion |     #endregion | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -7,5 +7,6 @@ public enum ScrobbleEventSortField | |||||||
|     LastModified = 2, |     LastModified = 2, | ||||||
|     Type= 3, |     Type= 3, | ||||||
|     Series = 4, |     Series = 4, | ||||||
|     IsProcessed = 5 |     IsProcessed = 5, | ||||||
|  |     ScrobbleEventFilter = 6 | ||||||
| } | } | ||||||
|  | |||||||
| @ -255,6 +255,7 @@ public static class QueryableExtensions | |||||||
|                 ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), |                 ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), | ||||||
|                 ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), |                 ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), | ||||||
|                 ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), |                 ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), | ||||||
|  |                 ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType), | ||||||
|                 _ => query |                 _ => query | ||||||
|             }; |             }; | ||||||
|         } |         } | ||||||
| @ -267,6 +268,7 @@ public static class QueryableExtensions | |||||||
|             ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), |             ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), | ||||||
|             ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), |             ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), | ||||||
|             ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), |             ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), | ||||||
|  |             ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType), | ||||||
|             _ => query |             _ => query | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -14,6 +14,8 @@ public static partial class StringHelper | |||||||
|     private static partial Regex BrMultipleRegex(); |     private static partial Regex BrMultipleRegex(); | ||||||
|     [GeneratedRegex(@"\s+")] |     [GeneratedRegex(@"\s+")] | ||||||
|     private static partial Regex WhiteSpaceRegex(); |     private static partial Regex WhiteSpaceRegex(); | ||||||
|  |     [GeneratedRegex("&#64;")] | ||||||
|  |     private static partial Regex HtmlEncodedAtSymbolRegex(); | ||||||
|     #endregion |     #endregion | ||||||
| 
 | 
 | ||||||
|     /// <summary> |     /// <summary> | ||||||
| @ -52,4 +54,16 @@ public static partial class StringHelper | |||||||
| 
 | 
 | ||||||
|         return SourceRegex().Replace(description, string.Empty).Trim(); |         return SourceRegex().Replace(description, string.Empty).Trim(); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="description"></param> | ||||||
|  |     /// <returns></returns> | ||||||
|  |     public static string? CorrectUrls(string? description) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(description)) return description; | ||||||
|  | 
 | ||||||
|  |         return HtmlEncodedAtSymbolRegex().Replace(description, "@"); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService | |||||||
|         { |         { | ||||||
|             if (string.IsNullOrEmpty(series.CoverImage)) continue; |             if (string.IsNullOrEmpty(series.CoverImage)) continue; | ||||||
|             series.CoverImage = series.GetCoverImage(); |             series.CoverImage = series.GetCoverImage(); | ||||||
|  |             if (series.CoverImage == null) | ||||||
|  |             { | ||||||
|  |                 _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); | ||||||
|  |             } | ||||||
|             _unitOfWork.SeriesRepository.Update(series); |             _unitOfWork.SeriesRepository.Update(series); | ||||||
|             await _unitOfWork.CommitAsync(); |             await _unitOfWork.CommitAsync(); | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -199,6 +199,10 @@ public class MetadataService : IMetadataService | |||||||
| 
 | 
 | ||||||
|         series.Volumes ??= []; |         series.Volumes ??= []; | ||||||
|         series.CoverImage = series.GetCoverImage(); |         series.CoverImage = series.GetCoverImage(); | ||||||
|  |         if (series.CoverImage == null) | ||||||
|  |         { | ||||||
|  |             _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); | ||||||
|  |         } | ||||||
| 
 | 
 | ||||||
|         _imageService.UpdateColorScape(series); |         _imageService.UpdateColorScape(series); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -76,7 +76,7 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|     }; |     }; | ||||||
|     // Allow 50 requests per 24 hours |     // Allow 50 requests per 24 hours | ||||||
|     private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); |     private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); | ||||||
|     static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); |     private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); | ||||||
| 
 | 
 | ||||||
|     public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, |     public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger, IMapper mapper, | ||||||
|         ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) |         ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) | ||||||
| @ -115,18 +115,24 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|         // Find all Series that are eligible and limit |         // Find all Series that are eligible and limit | ||||||
|         var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false); |         var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, false); | ||||||
|         if (ids.Count == 0) return; |         if (ids.Count == 0) return; | ||||||
|  |         ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); | ||||||
| 
 | 
 | ||||||
|         _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); |         _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); | ||||||
|         var count = 0; |         var count = 0; | ||||||
|  |         var successfulMatches = new List<int>(); | ||||||
|         var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); |         var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); | ||||||
|         foreach (var seriesId in ids) |         foreach (var seriesId in ids) | ||||||
|         { |         { | ||||||
|             var libraryType = libTypes[seriesId]; |             var libraryType = libTypes[seriesId]; | ||||||
|             var success = await FetchSeriesMetadata(seriesId, libraryType); |             var success = await FetchSeriesMetadata(seriesId, libraryType); | ||||||
|             if (success) count++; |             if (success) | ||||||
|  |             { | ||||||
|  |                 count++; | ||||||
|  |                 successfulMatches.Add(seriesId); | ||||||
|  |             } | ||||||
|             await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request |             await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request | ||||||
|         } |         } | ||||||
|         _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); |         _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -146,7 +152,7 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|         if (!RateLimiter.TryAcquire(string.Empty)) |         if (!RateLimiter.TryAcquire(string.Empty)) | ||||||
|         { |         { | ||||||
|             // Request not allowed due to rate limit |             // Request not allowed due to rate limit | ||||||
|             _logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); |             _logger.LogInformation("Rate Limit hit for Kavita+ prefetch"); | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
| @ -731,7 +737,7 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|             { |             { | ||||||
|                 Name = w.Name, |                 Name = w.Name, | ||||||
|                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), |                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), | ||||||
|                 Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), |                 Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), | ||||||
|             }) |             }) | ||||||
|             .Concat(series.Metadata.People |             .Concat(series.Metadata.People | ||||||
|                 .Where(p => p.Role == PersonRole.Character) |                 .Where(p => p.Role == PersonRole.Character) | ||||||
| @ -743,7 +749,9 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|             .ToList(); |             .ToList(); | ||||||
| 
 | 
 | ||||||
|         if (characters.Count == 0) return false; |         if (characters.Count == 0) return false; | ||||||
|  | 
 | ||||||
|         await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); |         await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); | ||||||
|  | 
 | ||||||
|         foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) |         foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) | ||||||
|         { |         { | ||||||
|             // Set a sort order based on their role |             // Set a sort order based on their role | ||||||
| @ -810,7 +818,7 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|             { |             { | ||||||
|                 Name = w.Name, |                 Name = w.Name, | ||||||
|                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), |                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), | ||||||
|                 Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), |                 Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), | ||||||
|             }) |             }) | ||||||
|             .Concat(series.Metadata.People |             .Concat(series.Metadata.People | ||||||
|                 .Where(p => p.Role == PersonRole.CoverArtist) |                 .Where(p => p.Role == PersonRole.CoverArtist) | ||||||
| @ -867,7 +875,7 @@ public class ExternalMetadataService : IExternalMetadataService | |||||||
|             { |             { | ||||||
|                 Name = w.Name, |                 Name = w.Name, | ||||||
|                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), |                 AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), | ||||||
|                 Description = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description)), |                 Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), | ||||||
|             }) |             }) | ||||||
|             .Concat(series.Metadata.People |             .Concat(series.Metadata.People | ||||||
|                 .Where(p => p.Role == PersonRole.Writer) |                 .Where(p => p.Role == PersonRole.Writer) | ||||||
|  | |||||||
| @ -552,14 +552,22 @@ public class CoverDbService : ICoverDbService | |||||||
| 
 | 
 | ||||||
|                 series.CoverImage = filePath; |                 series.CoverImage = filePath; | ||||||
|                 series.CoverImageLocked = true; |                 series.CoverImageLocked = true; | ||||||
|  |                 if (series.CoverImage == null) | ||||||
|  |                 { | ||||||
|  |                     _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); | ||||||
|  |                 } | ||||||
|                 _imageService.UpdateColorScape(series); |                 _imageService.UpdateColorScape(series); | ||||||
|                 _unitOfWork.SeriesRepository.Update(series); |                 _unitOfWork.SeriesRepository.Update(series); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         else |         else | ||||||
|         { |         { | ||||||
|             series.CoverImage = string.Empty; |             series.CoverImage = null; | ||||||
|             series.CoverImageLocked = false; |             series.CoverImageLocked = false; | ||||||
|  |             if (series.CoverImage == null) | ||||||
|  |             { | ||||||
|  |                 _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); | ||||||
|  |             } | ||||||
|             _imageService.UpdateColorScape(series); |             _imageService.UpdateColorScape(series); | ||||||
|             _unitOfWork.SeriesRepository.Update(series); |             _unitOfWork.SeriesRepository.Update(series); | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -278,7 +278,8 @@ public partial class VersionUpdaterService : IVersionUpdaterService | |||||||
|     { |     { | ||||||
|         // Attempt to fetch from cache |         // Attempt to fetch from cache | ||||||
|         var cachedReleases = await TryGetCachedReleases(); |         var cachedReleases = await TryGetCachedReleases(); | ||||||
|         if (cachedReleases != null) |         // If there is a cached release and the current version is within it, use it, otherwise regenerate | ||||||
|  |         if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString()))) | ||||||
|         { |         { | ||||||
|             if (count > 0) |             if (count > 0) | ||||||
|             { |             { | ||||||
| @ -338,6 +339,29 @@ public partial class VersionUpdaterService : IVersionUpdaterService | |||||||
|         return updateDtos; |         return updateDtos; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Compares 2 versions and ensures that the minor is always there | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="v1"></param> | ||||||
|  |     /// <param name="v2"></param> | ||||||
|  |     /// <returns></returns> | ||||||
|  |     private static bool IsVersionEqual(string v1, string v2) | ||||||
|  |     { | ||||||
|  |         var versionParts = v1.Split('.'); | ||||||
|  |         if (versionParts.Length < 4) | ||||||
|  |         { | ||||||
|  |             v1 += ".0"; // Append missing parts | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         versionParts = v2.Split('.'); | ||||||
|  |         if (versionParts.Length < 4) | ||||||
|  |         { | ||||||
|  |             v2 += ".0"; // Append missing parts | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases() |     private async Task<IList<UpdateNotificationDto>?> TryGetCachedReleases() | ||||||
|     { |     { | ||||||
|         if (!File.Exists(_cacheFilePath)) return null; |         if (!File.Exists(_cacheFilePath)) return null; | ||||||
| @ -370,7 +394,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService | |||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var json = System.Text.Json.JsonSerializer.Serialize(updates, JsonOptions); |             var json = JsonSerializer.Serialize(updates, JsonOptions); | ||||||
|             await File.WriteAllTextAsync(_cacheFilePath, json); |             await File.WriteAllTextAsync(_cacheFilePath, json); | ||||||
|         } |         } | ||||||
|         catch (Exception ex) |         catch (Exception ex) | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								UI/Web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								UI/Web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -542,6 +542,7 @@ | |||||||
|       "version": "19.2.3", |       "version": "19.2.3", | ||||||
|       "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz", |       "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz", | ||||||
|       "integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==", |       "integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==", | ||||||
|  |       "dev": true, | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|         "@babel/core": "7.26.9", |         "@babel/core": "7.26.9", | ||||||
|         "@jridgewell/sourcemap-codec": "^1.4.14", |         "@jridgewell/sourcemap-codec": "^1.4.14", | ||||||
| @ -569,6 +570,7 @@ | |||||||
|       "version": "4.0.1", |       "version": "4.0.1", | ||||||
|       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", |       "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", | ||||||
|       "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", |       "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", | ||||||
|  |       "dev": true, | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|         "readdirp": "^4.0.1" |         "readdirp": "^4.0.1" | ||||||
|       }, |       }, | ||||||
| @ -583,6 +585,7 @@ | |||||||
|       "version": "4.0.2", |       "version": "4.0.2", | ||||||
|       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", |       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", | ||||||
|       "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", |       "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", | ||||||
|  |       "dev": true, | ||||||
|       "engines": { |       "engines": { | ||||||
|         "node": ">= 14.16.0" |         "node": ">= 14.16.0" | ||||||
|       }, |       }, | ||||||
| @ -4904,7 +4907,8 @@ | |||||||
|     "node_modules/convert-source-map": { |     "node_modules/convert-source-map": { | ||||||
|       "version": "1.9.0", |       "version": "1.9.0", | ||||||
|       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", |       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", | ||||||
|       "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" |       "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", | ||||||
|  |       "dev": true | ||||||
|     }, |     }, | ||||||
|     "node_modules/cosmiconfig": { |     "node_modules/cosmiconfig": { | ||||||
|       "version": "8.3.6", |       "version": "8.3.6", | ||||||
| @ -5351,6 +5355,7 @@ | |||||||
|       "version": "0.1.13", |       "version": "0.1.13", | ||||||
|       "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", |       "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", | ||||||
|       "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", |       "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", | ||||||
|  |       "dev": true, | ||||||
|       "optional": true, |       "optional": true, | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|         "iconv-lite": "^0.6.2" |         "iconv-lite": "^0.6.2" | ||||||
| @ -5360,6 +5365,7 @@ | |||||||
|       "version": "0.6.3", |       "version": "0.6.3", | ||||||
|       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", |       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", | ||||||
|       "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", |       "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", | ||||||
|  |       "dev": true, | ||||||
|       "optional": true, |       "optional": true, | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|         "safer-buffer": ">= 2.1.2 < 3.0.0" |         "safer-buffer": ">= 2.1.2 < 3.0.0" | ||||||
| @ -8178,7 +8184,8 @@ | |||||||
|     "node_modules/reflect-metadata": { |     "node_modules/reflect-metadata": { | ||||||
|       "version": "0.2.2", |       "version": "0.2.2", | ||||||
|       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", |       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", | ||||||
|       "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" |       "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", | ||||||
|  |       "dev": true | ||||||
|     }, |     }, | ||||||
|     "node_modules/replace-in-file": { |     "node_modules/replace-in-file": { | ||||||
|       "version": "7.1.0", |       "version": "7.1.0", | ||||||
| @ -8399,7 +8406,7 @@ | |||||||
|       "version": "2.1.2", |       "version": "2.1.2", | ||||||
|       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", |       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", | ||||||
|       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", |       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", | ||||||
|       "devOptional": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|     "node_modules/sass": { |     "node_modules/sass": { | ||||||
|       "version": "1.85.0", |       "version": "1.85.0", | ||||||
| @ -8464,6 +8471,7 @@ | |||||||
|       "version": "7.7.1", |       "version": "7.7.1", | ||||||
|       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", |       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", | ||||||
|       "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", |       "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", | ||||||
|  |       "dev": true, | ||||||
|       "bin": { |       "bin": { | ||||||
|         "semver": "bin/semver.js" |         "semver": "bin/semver.js" | ||||||
|       }, |       }, | ||||||
| @ -9088,6 +9096,7 @@ | |||||||
|       "version": "5.5.4", |       "version": "5.5.4", | ||||||
|       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", |       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", | ||||||
|       "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", |       "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", | ||||||
|  |       "dev": true, | ||||||
|       "bin": { |       "bin": { | ||||||
|         "tsc": "bin/tsc", |         "tsc": "bin/tsc", | ||||||
|         "tsserver": "bin/tsserver" |         "tsserver": "bin/tsserver" | ||||||
|  | |||||||
| @ -1,4 +1,3 @@ | |||||||
| 
 |  | ||||||
| import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; | import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; | ||||||
| import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; | import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; | ||||||
| import {PageLayoutMode} from '../page-layout-mode'; | import {PageLayoutMode} from '../page-layout-mode'; | ||||||
| @ -25,6 +24,7 @@ export interface Preferences { | |||||||
|   showScreenHints: boolean; |   showScreenHints: boolean; | ||||||
|   emulateBook: boolean; |   emulateBook: boolean; | ||||||
|   swipeToPaginate: boolean; |   swipeToPaginate: boolean; | ||||||
|  |   allowAutomaticWebtoonReaderDetection: boolean; | ||||||
| 
 | 
 | ||||||
|   // Book Reader
 |   // Book Reader
 | ||||||
|   bookReaderMargin: number; |   bookReaderMargin: number; | ||||||
|  | |||||||
| @ -1,6 +1,9 @@ | |||||||
| import {LibraryType} from "./library/library"; | import {LibraryType} from "./library/library"; | ||||||
| import {MangaFormat} from "./manga-format"; | import {MangaFormat} from "./manga-format"; | ||||||
| import {IHasCover} from "./common/i-has-cover"; | import {IHasCover} from "./common/i-has-cover"; | ||||||
|  | import {AgeRating} from "./metadata/age-rating"; | ||||||
|  | import {IHasReadingTime} from "./common/i-has-reading-time"; | ||||||
|  | import {IHasCast} from "./common/i-has-cast"; | ||||||
| 
 | 
 | ||||||
| export interface ReadingListItem { | export interface ReadingListItem { | ||||||
|   pagesRead: number; |   pagesRead: number; | ||||||
| @ -39,4 +42,16 @@ export interface ReadingList extends IHasCover { | |||||||
|   endingYear: number; |   endingYear: number; | ||||||
|   endingMonth: number; |   endingMonth: number; | ||||||
|   itemCount: number; |   itemCount: number; | ||||||
|  |   ageRating: AgeRating; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { | ||||||
|  |   pages: number; | ||||||
|  |   wordCount: number; | ||||||
|  |   isAllEpub: boolean; | ||||||
|  |   minHoursToRead: number; | ||||||
|  |   maxHoursToRead: number; | ||||||
|  |   avgHoursToRead: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ReadingListCast extends IHasCast {} | ||||||
|  | |||||||
| @ -4,7 +4,8 @@ export enum ScrobbleEventSortField { | |||||||
|   LastModified = 2, |   LastModified = 2, | ||||||
|   Type= 3, |   Type= 3, | ||||||
|   Series = 4, |   Series = 4, | ||||||
|   IsProcessed = 5 |   IsProcessed = 5, | ||||||
|  |   ScrobbleEvent = 6 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface ScrobbleEventFilter { | export interface ScrobbleEventFilter { | ||||||
|  | |||||||
| @ -2,5 +2,4 @@ export interface HourEstimateRange{ | |||||||
|     minHours: number; |     minHours: number; | ||||||
|     maxHours: number; |     maxHours: number; | ||||||
|     avgHours: number; |     avgHours: number; | ||||||
|     //hasProgress: boolean;
 |  | ||||||
| } | } | ||||||
| @ -1,4 +1,4 @@ | |||||||
| import { HttpClient, HttpParams } from '@angular/common/http'; | import {HttpClient} from '@angular/common/http'; | ||||||
| import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; | import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; | ||||||
| import {DOCUMENT, Location} from '@angular/common'; | import {DOCUMENT, Location} from '@angular/common'; | ||||||
| import {Router} from '@angular/router'; | import {Router} from '@angular/router'; | ||||||
| @ -23,7 +23,6 @@ import {Volume} from "../_models/volume"; | |||||||
| import {UtilityService} from "../shared/_services/utility.service"; | import {UtilityService} from "../shared/_services/utility.service"; | ||||||
| import {translate} from "@jsverse/transloco"; | import {translate} from "@jsverse/transloco"; | ||||||
| import {ToastrService} from "ngx-toastr"; | import {ToastrService} from "ngx-toastr"; | ||||||
| import {getIosVersion, isSafari, Version} from "../_helpers/browser"; |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| export const CHAPTER_ID_DOESNT_EXIST = -1; | export const CHAPTER_ID_DOESNT_EXIST = -1; | ||||||
| @ -112,7 +111,6 @@ export class ReaderService { | |||||||
|     return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter); |     return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   getBookmarks(chapterId: number) { |   getBookmarks(chapterId: number) { | ||||||
|     return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId); |     return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -3,9 +3,9 @@ import { Injectable } from '@angular/core'; | |||||||
| import {map} from 'rxjs/operators'; | import {map} from 'rxjs/operators'; | ||||||
| import {environment} from 'src/environments/environment'; | import {environment} from 'src/environments/environment'; | ||||||
| import {UtilityService} from '../shared/_services/utility.service'; | import {UtilityService} from '../shared/_services/utility.service'; | ||||||
| import { Person } from '../_models/metadata/person'; | import {Person, PersonRole} from '../_models/metadata/person'; | ||||||
| import {PaginatedResult} from '../_models/pagination'; | import {PaginatedResult} from '../_models/pagination'; | ||||||
| import { ReadingList, ReadingListItem } from '../_models/reading-list'; | import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; | ||||||
| import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; | import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; | ||||||
| import {TextResonse} from '../_types/text-response'; | import {TextResonse} from '../_types/text-response'; | ||||||
| import {Action, ActionItem} from './action-factory.service'; | import {Action, ActionItem} from './action-factory.service'; | ||||||
| @ -20,7 +20,7 @@ export class ReadingListService { | |||||||
|   constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } |   constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } | ||||||
| 
 | 
 | ||||||
|   getReadingList(readingListId: number) { |   getReadingList(readingListId: number) { | ||||||
|     return this.httpClient.get<ReadingList>(this.baseUrl + 'readinglist?readingListId=' + readingListId); |     return this.httpClient.get<ReadingList | null>(this.baseUrl + 'readinglist?readingListId=' + readingListId); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { |   getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { | ||||||
| @ -114,10 +114,20 @@ export class ReadingListService { | |||||||
|     return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); |     return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getCharacters(readingListId: number) { |   getPeople(readingListId: number, role: PersonRole) { | ||||||
|     return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); |     return this.httpClient.get<Array<Person>>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   getAllPeople(readingListId: number) { | ||||||
|  |     return this.httpClient.get<ReadingListCast>(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   getReadingListInfo(readingListId: number) { | ||||||
|  |     return this.httpClient.get<ReadingListInfo>(this.baseUrl + `readinglist/info?readingListId=${readingListId}`); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|   promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) { |   promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) { | ||||||
|     return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); |     return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ import {filter, take} from "rxjs/operators"; | |||||||
| import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; | import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; | ||||||
| import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; | import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; | ||||||
| import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; | import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; | ||||||
|  | import {Router} from "@angular/router"; | ||||||
| 
 | 
 | ||||||
| @Injectable({ | @Injectable({ | ||||||
|   providedIn: 'root' |   providedIn: 'root' | ||||||
| @ -15,6 +16,7 @@ export class VersionService implements OnDestroy{ | |||||||
|   private readonly serverService = inject(ServerService); |   private readonly serverService = inject(ServerService); | ||||||
|   private readonly accountService = inject(AccountService); |   private readonly accountService = inject(AccountService); | ||||||
|   private readonly modalService = inject(NgbModal); |   private readonly modalService = inject(NgbModal); | ||||||
|  |   private readonly router = inject(Router); | ||||||
| 
 | 
 | ||||||
|   public static readonly SERVER_VERSION_KEY = 'kavita--version'; |   public static readonly SERVER_VERSION_KEY = 'kavita--version'; | ||||||
|   public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; |   public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; | ||||||
| @ -29,15 +31,23 @@ export class VersionService implements OnDestroy{ | |||||||
|   // Check intervals
 |   // Check intervals
 | ||||||
|   private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
 |   private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
 | ||||||
|   private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours
 |   private readonly OUT_OF_DATE_CHECK_INTERVAL = this.VERSION_CHECK_INTERVAL; // 2 * 60 * 60 * 1000; // 2 hours
 | ||||||
| 
 |  | ||||||
|   private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date"
 |   private readonly OUT_Of_BAND_AMOUNT = 2; // How many releases before we show "You're X releases out of date"
 | ||||||
| 
 | 
 | ||||||
|  |   // Routes where version update modals should not be shown
 | ||||||
|  |   private readonly EXCLUDED_ROUTES = [ | ||||||
|  |     '/manga/', | ||||||
|  |     '/book/', | ||||||
|  |     '/pdf/', | ||||||
|  |     '/reader/' | ||||||
|  |   ]; | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|   private versionCheckSubscription?: Subscription; |   private versionCheckSubscription?: Subscription; | ||||||
|   private outOfDateCheckSubscription?: Subscription; |   private outOfDateCheckSubscription?: Subscription; | ||||||
|   private modalOpen = false; |   private modalOpen = false; | ||||||
| 
 | 
 | ||||||
|   constructor() { |   constructor() { | ||||||
|  |     this.startInitialVersionCheck(); | ||||||
|     this.startVersionCheck(); |     this.startVersionCheck(); | ||||||
|     this.startOutOfDateCheck(); |     this.startOutOfDateCheck(); | ||||||
|   } |   } | ||||||
| @ -47,6 +57,26 @@ export class VersionService implements OnDestroy{ | |||||||
|     this.outOfDateCheckSubscription?.unsubscribe(); |     this.outOfDateCheckSubscription?.unsubscribe(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Initial version check to ensure localStorage is populated on first load | ||||||
|  |    */ | ||||||
|  |   private startInitialVersionCheck(): void { | ||||||
|  |     this.accountService.currentUser$ | ||||||
|  |       .pipe( | ||||||
|  |         filter(user => !!user), | ||||||
|  |         take(1), | ||||||
|  |         switchMap(user => this.serverService.getVersion(user!.apiKey)) | ||||||
|  |       ) | ||||||
|  |       .subscribe(serverVersion => { | ||||||
|  |         const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); | ||||||
|  | 
 | ||||||
|  |         // Always update localStorage on first load
 | ||||||
|  |         localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); | ||||||
|  | 
 | ||||||
|  |         console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion); | ||||||
|  |       }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Periodic check for server version to detect client refreshes and new updates |    * Periodic check for server version to detect client refreshes and new updates | ||||||
|    */ |    */ | ||||||
| @ -76,12 +106,26 @@ export class VersionService implements OnDestroy{ | |||||||
|       .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); |       .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Checks if the current route is in the excluded routes list | ||||||
|  |    */ | ||||||
|  |   private isExcludedRoute(): boolean { | ||||||
|  |     const currentUrl = this.router.url; | ||||||
|  |     return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Handles the version check response to determine if client refresh or new update notification is needed |    * Handles the version check response to determine if client refresh or new update notification is needed | ||||||
|    */ |    */ | ||||||
|   private handleVersionUpdate(serverVersion: string) { |   private handleVersionUpdate(serverVersion: string) { | ||||||
|     if (this.modalOpen) return; |     if (this.modalOpen) return; | ||||||
| 
 | 
 | ||||||
|  |     // Validate if we are on a reader route and if so, suppress
 | ||||||
|  |     if (this.isExcludedRoute()) { | ||||||
|  |       console.log('Version update blocked due to user reading'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); |     const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); | ||||||
|     console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); |     console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -28,12 +28,24 @@ | |||||||
|     </div> |     </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"> |     <div class="mb-3 ms-1"> | ||||||
|       <h4 class="header">{{t('format-title')}}</h4> |       <h4 class="header">{{t('format-title')}}</h4> | ||||||
|       <div class="ms-3"> |       <div class="ms-3"> | ||||||
|         <app-series-format [format]="format"></app-series-format> {{format | mangaFormat }} |         <app-series-format [format]="format"></app-series-format> {{format | mangaFormat }} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |   } | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|   <div class="setting-section-break" aria-hidden="true"></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 {LanguageNamePipe} from "../../_pipes/language-name.pipe"; | ||||||
| import {AsyncPipe} from "@angular/common"; | import {AsyncPipe} from "@angular/common"; | ||||||
| import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; | import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; | ||||||
|  | import {AgeRating} from "../../_models/metadata/age-rating"; | ||||||
|  | import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component"; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-details-tab', |   selector: 'app-details-tab', | ||||||
| @ -34,7 +36,8 @@ import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; | |||||||
|     MangaFormatPipe, |     MangaFormatPipe, | ||||||
|     LanguageNamePipe, |     LanguageNamePipe, | ||||||
|     AsyncPipe, |     AsyncPipe, | ||||||
|     SafeUrlPipe |     SafeUrlPipe, | ||||||
|  |     AgeRatingImageComponent | ||||||
|   ], |   ], | ||||||
|   templateUrl: './details-tab.component.html', |   templateUrl: './details-tab.component.html', | ||||||
|   styleUrl: './details-tab.component.scss', |   styleUrl: './details-tab.component.scss', | ||||||
| @ -47,11 +50,13 @@ export class DetailsTabComponent { | |||||||
| 
 | 
 | ||||||
|   protected readonly PersonRole = PersonRole; |   protected readonly PersonRole = PersonRole; | ||||||
|   protected readonly FilterField = FilterField; |   protected readonly FilterField = FilterField; | ||||||
|  |   protected readonly MangaFormat = MangaFormat; | ||||||
| 
 | 
 | ||||||
|   @Input({required: true}) metadata!: IHasCast; |   @Input({required: true}) metadata!: IHasCast; | ||||||
|   @Input() readingTime: IHasReadingTime | undefined; |   @Input() readingTime: IHasReadingTime | undefined; | ||||||
|  |   @Input() ageRating: AgeRating | undefined; | ||||||
|   @Input() language: string | undefined; |   @Input() language: string | undefined; | ||||||
|   @Input() format: MangaFormat = MangaFormat.UNKNOWN; |   @Input() format: MangaFormat | undefined; | ||||||
|   @Input() releaseYear: number | undefined; |   @Input() releaseYear: number | undefined; | ||||||
|   @Input() genres: Array<Genre> = []; |   @Input() genres: Array<Genre> = []; | ||||||
|   @Input() tags: Array<Tag> = []; |   @Input() tags: Array<Tag> = []; | ||||||
| @ -62,6 +67,4 @@ export class DetailsTabComponent { | |||||||
|     if (queryParamName === FilterField.None) return; |     if (queryParamName === FilterField.None) return; | ||||||
|     this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); |     this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   protected readonly MangaFormat = MangaFormat; |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -8,7 +8,7 @@ | |||||||
|     </div> |     </div> | ||||||
|     <div class="ms-1"> |     <div class="ms-1"> | ||||||
|       <div><span class="title">{{item.series.name}}</span> <span class="me-1 float-end">({{item.matchRating | translocoPercent}})</span></div> |       <div><span class="title">{{item.series.name}}</span> <span class="me-1 float-end">({{item.matchRating | translocoPercent}})</span></div> | ||||||
|       <div class="text-body-secondary"> |       <div class="text-muted"> | ||||||
|         @for(synm of item.series.synonyms; track synm; let last = $last) { |         @for(synm of item.series.synonyms; track synm; let last = $last) { | ||||||
|           {{synm}} |           {{synm}} | ||||||
|           @if (!last) { |           @if (!last) { | ||||||
|  | |||||||
| @ -8,9 +8,7 @@ import { | |||||||
|   Output |   Output | ||||||
| } from '@angular/core'; | } from '@angular/core'; | ||||||
| import {ImageComponent} from "../../shared/image/image.component"; | import {ImageComponent} from "../../shared/image/image.component"; | ||||||
| import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; |  | ||||||
| import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; | import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; | ||||||
| import {PercentPipe} from "@angular/common"; |  | ||||||
| import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; | import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; | ||||||
| import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; | import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; | ||||||
| import {TranslocoDirective} from "@jsverse/transloco"; | import {TranslocoDirective} from "@jsverse/transloco"; | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ | |||||||
|     [count]="pageInfo.totalElements" |     [count]="pageInfo.totalElements" | ||||||
|     [offset]="pageInfo.pageNumber" |     [offset]="pageInfo.pageNumber" | ||||||
|     [limit]="pageInfo.size" |     [limit]="pageInfo.size" | ||||||
|  |     [sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]" | ||||||
|   > |   > | ||||||
| 
 | 
 | ||||||
|     <ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false"> |     <ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false"> | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ import {debounceTime, take} from "rxjs/operators"; | |||||||
| import {PaginatedResult} from "../../_models/pagination"; | import {PaginatedResult} from "../../_models/pagination"; | ||||||
| import {SortEvent} from "../table/_directives/sortable-header.directive"; | import {SortEvent} from "../table/_directives/sortable-header.directive"; | ||||||
| import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; | import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; | ||||||
| import {TranslocoModule} from "@jsverse/transloco"; | import {translate, TranslocoModule} from "@jsverse/transloco"; | ||||||
| import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; | import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; | ||||||
| import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; | import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; | ||||||
| import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; | import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; | ||||||
| @ -18,6 +18,7 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt | |||||||
| import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; | import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; | ||||||
| import {AsyncPipe} from "@angular/common"; | import {AsyncPipe} from "@angular/common"; | ||||||
| import {AccountService} from "../../_services/account.service"; | import {AccountService} from "../../_services/account.service"; | ||||||
|  | import {ToastrService} from "ngx-toastr"; | ||||||
| 
 | 
 | ||||||
| export interface DataTablePage { | export interface DataTablePage { | ||||||
|   pageNumber: number, |   pageNumber: number, | ||||||
| @ -44,6 +45,7 @@ export class UserScrobbleHistoryComponent implements OnInit { | |||||||
|   private readonly scrobblingService = inject(ScrobblingService); |   private readonly scrobblingService = inject(ScrobblingService); | ||||||
|   private readonly cdRef = inject(ChangeDetectorRef); |   private readonly cdRef = inject(ChangeDetectorRef); | ||||||
|   private readonly destroyRef = inject(DestroyRef); |   private readonly destroyRef = inject(DestroyRef); | ||||||
|  |   private readonly toastr = inject(ToastrService); | ||||||
|   protected readonly accountService = inject(AccountService); |   protected readonly accountService = inject(AccountService); | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -60,6 +62,10 @@ export class UserScrobbleHistoryComponent implements OnInit { | |||||||
|     totalElements: 0, |     totalElements: 0, | ||||||
|     totalPages: 0 |     totalPages: 0 | ||||||
|   } |   } | ||||||
|  |   private currentSort: SortEvent<ScrobbleEvent> = { | ||||||
|  |     column: 'lastModifiedUtc', | ||||||
|  |     direction: 'desc' | ||||||
|  |   }; | ||||||
| 
 | 
 | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
| 
 | 
 | ||||||
| @ -73,26 +79,26 @@ export class UserScrobbleHistoryComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|     this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { |     this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { | ||||||
|       this.loadPage(); |       this.loadPage(); | ||||||
|     }) |     }); | ||||||
|  | 
 | ||||||
|  |     this.loadPage(this.currentSort); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onPageChange(pageInfo: any) { |   onPageChange(pageInfo: any) { | ||||||
|     this.pageInfo.pageNumber = pageInfo.offset; |     this.pageInfo.pageNumber = pageInfo.offset; | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
| 
 | 
 | ||||||
|     this.loadPage(); |     this.loadPage(this.currentSort); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   updateSort(data: any) { |   updateSort(data: any) { | ||||||
|     this.loadPage({column: data.column.prop, direction: data.newValue}); |     this.currentSort = { | ||||||
|  |       column: data.column.prop, | ||||||
|  |       direction: data.newValue | ||||||
|  |     }; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   loadPage(sortEvent?: SortEvent<ScrobbleEvent>) { |   loadPage(sortEvent?: SortEvent<ScrobbleEvent>) { | ||||||
|     if (sortEvent && this.pageInfo) { |  | ||||||
|       this.pageInfo.pageNumber = 1; |  | ||||||
|       this.cdRef.markForCheck(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const page = (this.pageInfo?.pageNumber || 0) + 1; |     const page = (this.pageInfo?.pageNumber || 0) + 1; | ||||||
|     const pageSize = this.pageInfo?.size || 0; |     const pageSize = this.pageInfo?.size || 0; | ||||||
|     const isDescending = sortEvent?.direction === 'desc'; |     const isDescending = sortEvent?.direction === 'desc'; | ||||||
| @ -102,7 +108,6 @@ export class UserScrobbleHistoryComponent implements OnInit { | |||||||
|     this.isLoading = true; |     this.isLoading = true; | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
| 
 | 
 | ||||||
|     // BUG: Table should be sorted by lastModifiedUtc by default
 |  | ||||||
|     this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) |     this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) | ||||||
|       .pipe(take(1)) |       .pipe(take(1)) | ||||||
|       .subscribe((result: PaginatedResult<ScrobbleEvent[]>) => { |       .subscribe((result: PaginatedResult<ScrobbleEvent[]>) => { | ||||||
| @ -122,13 +127,14 @@ export class UserScrobbleHistoryComponent implements OnInit { | |||||||
|       case 'isProcessed': return ScrobbleEventSortField.IsProcessed; |       case 'isProcessed': return ScrobbleEventSortField.IsProcessed; | ||||||
|       case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified; |       case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified; | ||||||
|       case 'seriesName': return ScrobbleEventSortField.Series; |       case 'seriesName': return ScrobbleEventSortField.Series; | ||||||
|  |       case 'scrobbleEventType': return ScrobbleEventSortField.ScrobbleEvent; | ||||||
|     } |     } | ||||||
|     return ScrobbleEventSortField.None; |     return ScrobbleEventSortField.None; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   generateScrobbleEvents() { |   generateScrobbleEvents() { | ||||||
|     this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { |     this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { | ||||||
| 
 |       this.toastr.info(translate('toasts.scrobble-gen-init')) | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -34,12 +34,12 @@ | |||||||
|     </ngx-datatable-column> |     </ngx-datatable-column> | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     <ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> |     <ngx-datatable-column prop="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1"> | ||||||
|       <ng-template let-column="column" ngx-datatable-header-template> |       <ng-template let-column="column" ngx-datatable-header-template> | ||||||
|         {{t('created-header')}} |         {{t('created-header')}} | ||||||
|       </ng-template> |       </ng-template> | ||||||
|       <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template> |       <ng-template let-item="row" let-idx="index" ngx-datatable-cell-template> | ||||||
|         {{item.createdUtc  | utcToLocalTime | defaultValue }} |         {{item.created  | utcToLocalTime | defaultValue }} | ||||||
|       </ng-template> |       </ng-template> | ||||||
|     </ngx-datatable-column> |     </ngx-datatable-column> | ||||||
| 
 | 
 | ||||||
| @ -57,9 +57,9 @@ | |||||||
|         {{t('edit-header')}} |         {{t('edit-header')}} | ||||||
|       </ng-template> |       </ng-template> | ||||||
|       <ng-template let-item="row" ngx-datatable-cell-template> |       <ng-template let-item="row" ngx-datatable-cell-template> | ||||||
|         <button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)"> |         <button class="btn btn-icon" (click)="fixMatch(item.seriesId)"> | ||||||
|           <i class="fa fa-pen me-1" aria-hidden="true"></i> |           <i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> | ||||||
|           <span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span> |           <span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span> | ||||||
|         </button> |         </button> | ||||||
|       </ng-template> |       </ng-template> | ||||||
|     </ngx-datatable-column> |     </ngx-datatable-column> | ||||||
|  | |||||||
| @ -20,15 +20,13 @@ import {ScrobblingService} from "../../_services/scrobbling.service"; | |||||||
| import {ScrobbleError} from "../../_models/scrobbling/scrobble-error"; | import {ScrobbleError} from "../../_models/scrobbling/scrobble-error"; | ||||||
| 
 | 
 | ||||||
| import {SeriesService} from "../../_services/series.service"; | import {SeriesService} from "../../_services/series.service"; | ||||||
| import {EditSeriesModalComponent} from "../../cards/_modals/edit-series-modal/edit-series-modal.component"; |  | ||||||
| import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; |  | ||||||
| import {FilterPipe} from "../../_pipes/filter.pipe"; | import {FilterPipe} from "../../_pipes/filter.pipe"; | ||||||
| import {TranslocoModule} from "@jsverse/transloco"; | import {TranslocoModule} from "@jsverse/transloco"; | ||||||
| import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; | import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; | ||||||
| import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; | import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; | ||||||
| import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; | import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; | ||||||
| import {DefaultModalOptions} from "../../_models/default-modal-options"; |  | ||||||
| import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; | import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; | ||||||
|  | import {ActionService} from "../../_services/action.service"; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|     selector: 'app-manage-scrobble-errors', |     selector: 'app-manage-scrobble-errors', | ||||||
| @ -38,14 +36,20 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; | |||||||
|     changeDetection: ChangeDetectionStrategy.OnPush |     changeDetection: ChangeDetectionStrategy.OnPush | ||||||
| }) | }) | ||||||
| export class ManageScrobbleErrorsComponent implements OnInit { | export class ManageScrobbleErrorsComponent implements OnInit { | ||||||
|   @Output() scrobbleCount = new EventEmitter<number>(); |   protected readonly filter = filter; | ||||||
|   @ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>; |   protected readonly ColumnMode = ColumnMode; | ||||||
|  | 
 | ||||||
|   private readonly scrobbleService = inject(ScrobblingService); |   private readonly scrobbleService = inject(ScrobblingService); | ||||||
|   private readonly messageHub = inject(MessageHubService); |   private readonly messageHub = inject(MessageHubService); | ||||||
|   private readonly cdRef = inject(ChangeDetectorRef); |   private readonly cdRef = inject(ChangeDetectorRef); | ||||||
|   private readonly destroyRef = inject(DestroyRef); |   private readonly destroyRef = inject(DestroyRef); | ||||||
|   private readonly seriesService = inject(SeriesService); |   private readonly seriesService = inject(SeriesService); | ||||||
|   private readonly modalService = inject(NgbModal); |   private readonly actionService = inject(ActionService); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   @Output() scrobbleCount = new EventEmitter<number>(); | ||||||
|  |   @ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>; | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
|   messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay()); |   messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(m => m.event === EVENTS.ScanSeries), shareReplay()); | ||||||
|   currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'}); |   currentSort = new BehaviorSubject<SortEvent<ScrobbleError>>({column: 'created', direction: 'asc'}); | ||||||
| @ -58,8 +62,6 @@ export class ManageScrobbleErrorsComponent implements OnInit { | |||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|   constructor() {} |  | ||||||
| 
 |  | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
| 
 | 
 | ||||||
|     this.loadData(); |     this.loadData(); | ||||||
| @ -108,13 +110,13 @@ export class ManageScrobbleErrorsComponent implements OnInit { | |||||||
|     return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0; |     return listItem.comment.toLowerCase().indexOf(query) >= 0 || listItem.details.toLowerCase().indexOf(query) >= 0; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   editSeries(seriesId: number) { |   fixMatch(seriesId: number) { | ||||||
|     this.seriesService.getSeries(seriesId).subscribe(series => { |     this.seriesService.getSeries(seriesId).subscribe(series => { | ||||||
|       const modalRef = this.modalService.open(EditSeriesModalComponent, DefaultModalOptions); |       this.actionService.matchSeries(series, (result) => { | ||||||
|       modalRef.componentInstance.series = series; |         if (!result) return; | ||||||
|  |         this.data = [...this.data.filter(s => s.seriesId !== series.id)]; | ||||||
|  |         this.cdRef.markForCheck(); | ||||||
|  |       }); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|   protected readonly filter = filter; |  | ||||||
|   protected readonly ColumnMode = ColumnMode; |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -7,7 +7,8 @@ import {AccountService} from "../../../_services/account.service"; | |||||||
| 
 | 
 | ||||||
| import { | import { | ||||||
|   NgbAccordionBody, |   NgbAccordionBody, | ||||||
|   NgbAccordionButton, NgbAccordionCollapse, |   NgbAccordionButton, | ||||||
|  |   NgbAccordionCollapse, | ||||||
|   NgbAccordionDirective, |   NgbAccordionDirective, | ||||||
|   NgbAccordionHeader, |   NgbAccordionHeader, | ||||||
|   NgbAccordionItem |   NgbAccordionItem | ||||||
| @ -32,7 +33,7 @@ export class ChangelogComponent implements OnInit { | |||||||
|   isLoading: boolean = true; |   isLoading: boolean = true; | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
|     this.serverService.getChangelog(30).subscribe(updates => { |     this.serverService.getChangelog(7).subscribe(updates => { | ||||||
|       this.updates = updates; |       this.updates = updates; | ||||||
|       this.isLoading = false; |       this.isLoading = false; | ||||||
|       this.cdRef.markForCheck(); |       this.cdRef.markForCheck(); | ||||||
|  | |||||||
| @ -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> |       {{t('description-part-1')}} <a [href]="WikiLink.SeriesRelationships" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{t('description-part-2')}}</a> | ||||||
|     </p> |     </p> | ||||||
| 
 | 
 | ||||||
|     <div class="row g-0" *ngIf="relations.length > 0"> |     @if (relations.length > 0) { | ||||||
|  |       <div class="row"> | ||||||
|         <label class="form-label col-md-7">{{t('target-series')}}</label> |         <label class="form-label col-md-7">{{t('target-series')}}</label> | ||||||
|         <label class="form-label col-md-5">{{t('relationship')}}</label> |         <label class="form-label col-md-5">{{t('relationship')}}</label> | ||||||
|       </div> |       </div> | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     <form> |     <form> | ||||||
|       <div class="row g-0" *ngFor="let relation of relations; let idx = index; let isLast = last;"> |       @for(relation of relations; let idx = $index; track idx) { | ||||||
|  |         <div class="row"> | ||||||
|           <div class="col-sm-12 col-md-12 col-lg-7 mb-3"> |           <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"> |             <app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead"> | ||||||
|               <ng-template #badgeItem let-item let-position="idx"> |               <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"> |           <div class="col-sm-12 col-md-10 col-lg-3 mb-3"> | ||||||
|             <select class="form-select" [formControl]="relation.formControl"> |             <select class="form-select" [formControl]="relation.formControl"> | ||||||
|               <option [value]="RelationKind.Parent" disabled>{{t('parent')}}</option> |               <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> |             </select> | ||||||
|           </div> |           </div> | ||||||
|           <button class="col-sm-auto col-md-2 mb-3 btn btn-outline-secondary" (click)="removeRelation(idx)"> |           <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> |         </div> | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|     </form> |     </form> | ||||||
| 
 | 
 | ||||||
|     <div class="row g-0 mt-3 mb-3"> |     <div class="row g-0 mt-3 mb-3"> | ||||||
|  | |||||||
| @ -1,7 +1,8 @@ | |||||||
| import { | import { | ||||||
|   ChangeDetectionStrategy, |   ChangeDetectionStrategy, | ||||||
|   ChangeDetectorRef, |   ChangeDetectorRef, | ||||||
|   Component, DestroyRef, |   Component, | ||||||
|  |   DestroyRef, | ||||||
|   EventEmitter, |   EventEmitter, | ||||||
|   inject, |   inject, | ||||||
|   Input, |   Input, | ||||||
| @ -9,7 +10,7 @@ import { | |||||||
|   Output |   Output | ||||||
| } from '@angular/core'; | } from '@angular/core'; | ||||||
| import {FormControl, ReactiveFormsModule} from '@angular/forms'; | import {FormControl, ReactiveFormsModule} from '@angular/forms'; | ||||||
| import { map, Observable, of, firstValueFrom, ReplaySubject } from 'rxjs'; | import {firstValueFrom, map, Observable, of, ReplaySubject} from 'rxjs'; | ||||||
| import {UtilityService} from 'src/app/shared/_services/utility.service'; | import {UtilityService} from 'src/app/shared/_services/utility.service'; | ||||||
| import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings'; | import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings'; | ||||||
| import {SearchResult} from 'src/app/_models/search/search-result'; | import {SearchResult} from 'src/app/_models/search/search-result'; | ||||||
| @ -21,7 +22,6 @@ import { SearchService } from 'src/app/_services/search.service'; | |||||||
| import {SeriesService} from 'src/app/_services/series.service'; | import {SeriesService} from 'src/app/_services/series.service'; | ||||||
| import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; | import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; | ||||||
| import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; | import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; | ||||||
| import {CommonModule} from "@angular/common"; |  | ||||||
| import {TranslocoModule} from "@jsverse/transloco"; | import {TranslocoModule} from "@jsverse/transloco"; | ||||||
| import {RelationshipPipe} from "../../_pipes/relationship.pipe"; | import {RelationshipPipe} from "../../_pipes/relationship.pipe"; | ||||||
| import {WikiLink} from "../../_models/wiki"; | import {WikiLink} from "../../_models/wiki"; | ||||||
| @ -36,7 +36,6 @@ interface RelationControl { | |||||||
|     selector: 'app-edit-series-relation', |     selector: 'app-edit-series-relation', | ||||||
|     imports: [ |     imports: [ | ||||||
|         TypeaheadComponent, |         TypeaheadComponent, | ||||||
|         CommonModule, |  | ||||||
|         ReactiveFormsModule, |         ReactiveFormsModule, | ||||||
|         TranslocoModule, |         TranslocoModule, | ||||||
|         RelationshipPipe, |         RelationshipPipe, | ||||||
| @ -113,7 +112,8 @@ export class EditSeriesRelationComponent implements OnInit { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async addNewRelation() { |   async addNewRelation() { | ||||||
|     this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); |     this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), | ||||||
|  |       typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
| 
 | 
 | ||||||
|     // Focus on the new typeahead
 |     // Focus on the new typeahead
 | ||||||
|  | |||||||
| @ -66,7 +66,7 @@ | |||||||
| 
 | 
 | ||||||
|     <div class="card-title-container"> |     <div class="card-title-container"> | ||||||
|       <app-series-format [format]="series.format"></app-series-format> |       <app-series-format [format]="series.format"></app-series-format> | ||||||
|       <span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}"> |       <span class="card-title" [ngbTooltip]="series.name" placement="top" id="{{series.id}}"> | ||||||
|         <a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}"> |         <a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}"> | ||||||
|           {{series.name}} |           {{series.name}} | ||||||
|         </a> |         </a> | ||||||
|  | |||||||
| @ -1,9 +1,10 @@ | |||||||
| import {DOCUMENT, AsyncPipe, NgStyle} from '@angular/common'; | import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common'; | ||||||
| import { | import { | ||||||
|   AfterViewInit, |   AfterViewInit, | ||||||
|   ChangeDetectionStrategy, |   ChangeDetectionStrategy, | ||||||
|   ChangeDetectorRef, |   ChangeDetectorRef, | ||||||
|   Component, DestroyRef, |   Component, | ||||||
|  |   DestroyRef, | ||||||
|   ElementRef, |   ElementRef, | ||||||
|   EventEmitter, |   EventEmitter, | ||||||
|   inject, |   inject, | ||||||
| @ -14,7 +15,8 @@ import { | |||||||
|   OnInit, |   OnInit, | ||||||
|   Output, |   Output, | ||||||
|   Renderer2, |   Renderer2, | ||||||
|   SimpleChanges, ViewChild |   SimpleChanges, | ||||||
|  |   ViewChild | ||||||
| } from '@angular/core'; | } from '@angular/core'; | ||||||
| import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; | import {BehaviorSubject, fromEvent, map, Observable, of, ReplaySubject} from 'rxjs'; | ||||||
| import {debounceTime} from 'rxjs/operators'; | import {debounceTime} from 'rxjs/operators'; | ||||||
| @ -352,17 +354,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, | |||||||
|       this.cdRef.markForCheck(); |       this.cdRef.markForCheck(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!this.isScrolling) { |     // if (!this.isScrolling) {
 | ||||||
|       // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
 |     //   // Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
 | ||||||
|       // to mark the current page and separate the prefetching code.
 |     //   // to mark the current page and separate the prefetching code.
 | ||||||
|       const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]')) |     //   const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
 | ||||||
|       .filter(entry => this.shouldElementCountAsCurrentPage(entry)); |     //   .filter(entry => this.shouldElementCountAsCurrentPage(entry));
 | ||||||
| 
 |     //
 | ||||||
|       if (midlineImages.length > 0) { |     //   if (midlineImages.length > 0) {
 | ||||||
|         this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10)); |     //     this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
 | ||||||
|       } |     //   }
 | ||||||
|     } |     // }
 | ||||||
| 
 |     //
 | ||||||
|     // Check if we hit the last page
 |     // Check if we hit the last page
 | ||||||
|     this.checkIfShouldTriggerContinuousReader(); |     this.checkIfShouldTriggerContinuousReader(); | ||||||
|   } |   } | ||||||
| @ -426,8 +428,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, | |||||||
|         this.checkIfShouldTriggerContinuousReader() |         this.checkIfShouldTriggerContinuousReader() | ||||||
|       } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { |       } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { | ||||||
|         // This if statement will fire once we scroll into the spacer at all
 |         // This if statement will fire once we scroll into the spacer at all
 | ||||||
|         this.loadNextChapter.emit(); |         this.moveToNextChapter(); | ||||||
|         this.cdRef.markForCheck(); |  | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|       // < 5 because debug mode and FF (mobile) can report non 0, despite being at 0
 |       // < 5 because debug mode and FF (mobile) can report non 0, despite being at 0
 | ||||||
| @ -442,7 +443,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, | |||||||
|         const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body; |         const reader = this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body; | ||||||
|         requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader)); |         requestAnimationFrame(() => this.scrollService.scrollTo((SPACER_SCROLL_INTO_PX / 2), reader)); | ||||||
|       } else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) { |       } else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) { | ||||||
|         // If already at top, then we moving on
 |         // If already at top, then we are moving on
 | ||||||
|         this.loadPrevChapter.emit(); |         this.loadPrevChapter.emit(); | ||||||
|         this.cdRef.markForCheck(); |         this.cdRef.markForCheck(); | ||||||
|       } |       } | ||||||
| @ -597,7 +598,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, | |||||||
|   handleBottomIntersection(entries: IntersectionObserverEntry[]) { |   handleBottomIntersection(entries: IntersectionObserverEntry[]) { | ||||||
|     if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) { |     if (entries.length > 0 && this.pageNum > this.totalPages - 5 && this.initFinished) { | ||||||
|       this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting); |       this.debugLog('[Intersection] The whole bottom spacer is visible', entries[0].isIntersecting); | ||||||
|       this.loadNextChapter.emit(); |       this.moveToNextChapter(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -617,6 +618,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Move to the next chapter and set the page | ||||||
|  |    */ | ||||||
|  |   moveToNextChapter() { | ||||||
|  |     this.setPageNum(this.totalPages); | ||||||
|  |     this.loadNextChapter.emit(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Set the page number, invoke prefetching and optionally scroll to the new page. |    * Set the page number, invoke prefetching and optionally scroll to the new page. | ||||||
|    * @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter. |    * @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter. | ||||||
|  | |||||||
| @ -621,11 +621,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|       this.cdRef.markForCheck(); |       this.cdRef.markForCheck(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     // fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => {
 |  | ||||||
|     //   if (event.detail > 1) return;
 |  | ||||||
|     //   this.toggleMenu();
 |  | ||||||
|     // });
 |  | ||||||
| 
 |  | ||||||
|     fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => { |     fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => { | ||||||
|       this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0; |       this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0; | ||||||
|       this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0; |       this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0; | ||||||
| @ -640,6 +635,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
|     this.navService.showSideNav(); |     this.navService.showSideNav(); | ||||||
|     this.showBookmarkEffectEvent.complete(); |     this.showBookmarkEffectEvent.complete(); | ||||||
|     if (this.goToPageEvent !== undefined) this.goToPageEvent.complete(); |     if (this.goToPageEvent !== undefined) this.goToPageEvent.complete(); | ||||||
|  | 
 | ||||||
|     this.readerService.disableWakeLock(); |     this.readerService.disableWakeLock(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -784,6 +780,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { | |||||||
| 
 | 
 | ||||||
|   switchToWebtoonReaderIfPagesLikelyWebtoon() { |   switchToWebtoonReaderIfPagesLikelyWebtoon() { | ||||||
|     if (this.readerMode === ReaderMode.Webtoon) return; |     if (this.readerMode === ReaderMode.Webtoon) return; | ||||||
|  |     if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return; | ||||||
| 
 | 
 | ||||||
|     if (this.mangaReaderService.shouldBeWebtoonMode()) { |     if (this.mangaReaderService.shouldBeWebtoonMode()) { | ||||||
|       this.readerMode = ReaderMode.Webtoon; |       this.readerMode = ReaderMode.Webtoon; | ||||||
|  | |||||||
| @ -113,10 +113,12 @@ export class MangaReaderService { | |||||||
|     return !(this.isNoSplit(pageSplitOption) || !needsSplitting) |     return !(this.isNoSplit(pageSplitOption) || !needsSplitting) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Some pages aren't cover images but might need fit split renderings | ||||||
|  |    * @param pageSplitOption | ||||||
|  |    */ | ||||||
|   shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) { |   shouldRenderAsFitSplit(pageSplitOption: PageSplitOption) { | ||||||
|     // Some pages aren't cover images but might need fit split renderings
 |     return parseInt(pageSplitOption + '', 10) === PageSplitOption.FitSplit; | ||||||
|     if (parseInt(pageSplitOption + '', 10) !== PageSplitOption.FitSplit) return false; |  | ||||||
|     return true; |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -156,27 +158,97 @@ export class MangaReaderService { | |||||||
|   shouldBeWebtoonMode() { |   shouldBeWebtoonMode() { | ||||||
|     const pages = Object.values(this.pageDimensions); |     const pages = Object.values(this.pageDimensions); | ||||||
| 
 | 
 | ||||||
|  |     // Require a minimum number of pages for reliable detection
 | ||||||
|  |     if (pages.length < 3) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Get statistical properties across all pages
 | ||||||
|  |     const aspectRatios = pages.map(info => info.height / info.width); | ||||||
|  |     const avgAspectRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / pages.length; | ||||||
|  |     const stdDevAspectRatio = Math.sqrt( | ||||||
|  |       aspectRatios.reduce((sum, ratio) => sum + Math.pow(ratio - avgAspectRatio, 2), 0) / pages.length | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     // Consider page dimensions consistency
 | ||||||
|  |     const widths = pages.map(info => info.width); | ||||||
|  |     const heights = pages.map(info => info.height); | ||||||
|  |     const avgWidth = widths.reduce((sum, w) => sum + w, 0) / pages.length; | ||||||
|  |     const avgHeight = heights.reduce((sum, h) => sum + h, 0) / pages.length; | ||||||
|  | 
 | ||||||
|  |     // Calculate variation coefficients for width and height
 | ||||||
|  |     const widthVariation = Math.sqrt( | ||||||
|  |       widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / pages.length | ||||||
|  |     ) / avgWidth; | ||||||
|  | 
 | ||||||
|  |     // Calculate individual scores for each page
 | ||||||
|     let webtoonScore = 0; |     let webtoonScore = 0; | ||||||
|  |     let strongIndicatorCount = 0; | ||||||
|  | 
 | ||||||
|     pages.forEach(info => { |     pages.forEach(info => { | ||||||
|       const aspectRatio = info.height / info.width; |       const aspectRatio = info.height / info.width; | ||||||
|       let score = 0; |       let score = 0; | ||||||
| 
 | 
 | ||||||
|       // Strong webtoon indicator: If aspect ratio is at least 2:1
 |       // Strong webtoon indicator: If aspect ratio is at least 2:1
 | ||||||
|       if (aspectRatio >= 2) { |       if (aspectRatio >= 2.2) { | ||||||
|         score += 1; |         score += 1; | ||||||
|  |         strongIndicatorCount++; | ||||||
|  |       } else if (aspectRatio >= 1.8 && aspectRatio < 2.2) { | ||||||
|  |         // Moderate indicator
 | ||||||
|  |         score += 0.5; | ||||||
|  |       } else if (aspectRatio >= 1.5 && aspectRatio < 1.8) { | ||||||
|  |         // Weak indicator - many regular manga/comics have ratios in this range
 | ||||||
|  |         score += 0.2; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // Boost score if width is small (≤ 800px, common in webtoons)
 |       // Penalize pages that are too square-like (common in traditional comics)
 | ||||||
|  |       if (aspectRatio < 1.2) { | ||||||
|  |         score -= 0.5; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Consider width but with less weight than before
 | ||||||
|       if (info.width <= 750) { |       if (info.width <= 750) { | ||||||
|         score += 0.5; // Adjust weight as needed
 |         score += 0.2; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Consider absolute height (long strips tend to be very tall)
 | ||||||
|  |       if (info.height > 2000) { | ||||||
|  |         score += 0.5; | ||||||
|  |       } else if (info.height > 1500) { | ||||||
|  |         score += 0.3; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Consider absolute page area - webtoons tend to have larger total area
 | ||||||
|  |       const area = info.width * info.height; | ||||||
|  |       if (area > 1500000) { // e.g., 1000×1500 or larger
 | ||||||
|  |         score += 0.3; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       webtoonScore += score; |       webtoonScore += score; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     const averageScore = webtoonScore / pages.length; | ||||||
| 
 | 
 | ||||||
|     // If at least 50% of the pages fit the webtoon criteria, switch to Webtoon mode.
 |     // Multiple criteria for more robust detection
 | ||||||
|     return webtoonScore / pages.length >= 0.5; |     // Check for typical manga/comic dimensions that should NOT be webtoon mode
 | ||||||
|  |     const isMangaLikeSize = avgHeight < 1200 && avgAspectRatio < 1.7 && avgWidth < 700; | ||||||
|  | 
 | ||||||
|  |     // Main detection criteria
 | ||||||
|  |     return ( | ||||||
|  |       // Primary criterion: average score threshold (increased)
 | ||||||
|  |       averageScore >= 0.7 && | ||||||
|  |       // Not resembling typical manga/comic dimensions
 | ||||||
|  |       !isMangaLikeSize && | ||||||
|  |       // Secondary criteria (any one can satisfy)
 | ||||||
|  |       ( | ||||||
|  |         // Most pages should have high aspect ratio
 | ||||||
|  |         (strongIndicatorCount / pages.length >= 0.4) || | ||||||
|  |         // Average aspect ratio is high enough (increased threshold)
 | ||||||
|  |         (avgAspectRatio >= 2.0) || | ||||||
|  |         // Pages have consistent width AND very high aspect ratio
 | ||||||
|  |         (widthVariation < 0.15 && avgAspectRatio > 1.8) | ||||||
|  |       ) | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| <ng-container *transloco="let t; read: 'draggable-ordered-list'"> | <ng-container *transloco="let t; read: 'draggable-ordered-list'"> | ||||||
| 
 |     @if (items.length > virtualizeAfter) { | ||||||
|     <ng-container *ngIf="items.length > virtualizeAfter; else dragList"> |  | ||||||
|       <div class="example-list list-group-flush"> |       <div class="example-list list-group-flush"> | ||||||
|         <virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll"> |         <virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll"> | ||||||
|           <div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity"> |           <div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity"> | ||||||
| @ -14,46 +13,55 @@ | |||||||
|           </div> |           </div> | ||||||
|         </virtual-scroller> |         </virtual-scroller> | ||||||
|       </div> |       </div> | ||||||
|     </ng-container> |     } @else { | ||||||
| 
 |  | ||||||
|     <ng-template #dragList> |  | ||||||
|       <div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)"> |       <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" |                [cdkDragData]="item" cdkDragBoundary=".example-list" | ||||||
|                [cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent"> |                [cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent"> | ||||||
|             <div class="d-flex list-container"> |             <div class="d-flex list-container"> | ||||||
|               <ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container> |               <ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container> | ||||||
|               <ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container> |               <ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container> | ||||||
| 
 | 
 | ||||||
|  |               @if (showRemoveButton) { | ||||||
|                 <ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container> |                 <ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container> | ||||||
|  |               } | ||||||
|  | 
 | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|  |         } | ||||||
|       </div> |       </div> | ||||||
|     </ng-template> |     } | ||||||
| 
 | 
 | ||||||
|     <ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized"> |     <ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized"> | ||||||
|         <div class="me-3 align-middle"> |         <div class="me-3 align-middle"> | ||||||
|             <div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}" *ngIf="accessibilityMode || bulkMode"> |           @if (accessibilityMode || bulkMode) { | ||||||
|                 <ng-container *ngIf="accessibilityMode"> |             <div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}"> | ||||||
|  |               @if (accessibilityMode) { | ||||||
|                 <label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label> |                 <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" |                 <input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0" | ||||||
|                        [max]="items.length - 1" [value]="item.order" |                        [max]="items.length - 1" [value]="item.order" | ||||||
|                        (focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions"> |                        (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> |                 <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)" |                 <input id="select-{{idx}}" class="form-check-input mt-0" type="checkbox" (change)="selectItem($event, idx)" | ||||||
|                        [ngModel]="bulkSelectionService.isCardSelected('sideNavStream', idx)" [ngModelOptions]="{standalone: true}"> |                        [ngModel]="bulkSelectionService.isCardSelected('sideNavStream', idx)" [ngModelOptions]="{standalone: true}"> | ||||||
|                 </ng-container> |               } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|             </div> |             </div> | ||||||
|             <i *ngIf="!isVirtualized && !(accessibilityMode || bulkMode) && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i> |           } | ||||||
|  | 
 | ||||||
|  |           @if (!isVirtualized && !(accessibilityMode || bulkMode) && !disabled) { | ||||||
|  |             <i class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i> | ||||||
|  |           } | ||||||
|         </div> |         </div> | ||||||
|     </ng-template> |     </ng-template> | ||||||
| 
 | 
 | ||||||
|     <ng-template #removeBtn let-item let-idx> |     <ng-template #removeBtn let-item let-idx> | ||||||
|         <button class="btn btn-icon float-end" (click)="removeItem(item, idx)" *ngIf="showRemoveButton" [disabled]="disabled"> |         <button class="btn btn-icon float-end" (click)="removeItem(item, idx)" [disabled]="disableRemove"> | ||||||
|             <i class="fa fa-times" aria-hidden="true"></i> |             <i class="fa fa-times" aria-hidden="true"></i> | ||||||
|             <span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span> |             <span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span> | ||||||
|         </button> |         </button> | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ import { | |||||||
|   TrackByFunction |   TrackByFunction | ||||||
| } from '@angular/core'; | } from '@angular/core'; | ||||||
| import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; | import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; | ||||||
| import {NgClass, NgFor, NgIf, NgTemplateOutlet} from '@angular/common'; | import {NgClass, NgFor, NgTemplateOutlet} from '@angular/common'; | ||||||
| import {TranslocoDirective} from "@jsverse/transloco"; | import {TranslocoDirective} from "@jsverse/transloco"; | ||||||
| import {BulkSelectionService} from "../../../cards/bulk-selection.service"; | import {BulkSelectionService} from "../../../cards/bulk-selection.service"; | ||||||
| import {FormsModule} from "@angular/forms"; | import {FormsModule} from "@angular/forms"; | ||||||
| @ -36,11 +36,15 @@ export interface ItemRemoveEvent { | |||||||
|   templateUrl: './draggable-ordered-list.component.html', |   templateUrl: './draggable-ordered-list.component.html', | ||||||
|   styleUrls: ['./draggable-ordered-list.component.scss'], |   styleUrls: ['./draggable-ordered-list.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
|   imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, |   imports: [VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, | ||||||
|     CdkDragHandle, TranslocoDirective, NgClass, FormsModule] |     CdkDragHandle, TranslocoDirective, NgClass, FormsModule] | ||||||
| }) | }) | ||||||
| export class DraggableOrderedListComponent { | export class DraggableOrderedListComponent { | ||||||
| 
 | 
 | ||||||
|  |   protected readonly bulkSelectionService = inject(BulkSelectionService); | ||||||
|  |   private readonly destroyRef = inject(DestroyRef); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * After this many elements, drag and drop is disabled and we use a virtualized list instead |    * After this many elements, drag and drop is disabled and we use a virtualized list instead | ||||||
|    */ |    */ | ||||||
| @ -59,6 +63,10 @@ export class DraggableOrderedListComponent { | |||||||
|    * Disables drag and drop functionality. Useful if a filter is present which will skew actual index. |    * Disables drag and drop functionality. Useful if a filter is present which will skew actual index. | ||||||
|    */ |    */ | ||||||
|   @Input() disabled: boolean = false; |   @Input() disabled: boolean = false; | ||||||
|  |   /** | ||||||
|  |    * Disables remove button | ||||||
|  |    */ | ||||||
|  |   @Input() disableRemove: boolean = false; | ||||||
|   /** |   /** | ||||||
|    * When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle |    * When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle | ||||||
|    */ |    */ | ||||||
| @ -71,8 +79,6 @@ export class DraggableOrderedListComponent { | |||||||
|   @Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>(); |   @Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>(); | ||||||
|   @ContentChild('draggableItem') itemTemplate!: TemplateRef<any>; |   @ContentChild('draggableItem') itemTemplate!: TemplateRef<any>; | ||||||
| 
 | 
 | ||||||
|   public readonly bulkSelectionService = inject(BulkSelectionService); |  | ||||||
|   public readonly destroyRef = inject(DestroyRef); |  | ||||||
| 
 | 
 | ||||||
|   get BufferAmount() { |   get BufferAmount() { | ||||||
|     return Math.min(this.items.length / 20, 20); |     return Math.min(this.items.length / 20, 20); | ||||||
|  | |||||||
| @ -1,70 +1,52 @@ | |||||||
| <div class="main-container container-fluid"> | <div class="main-container container-fluid"> | ||||||
|   <ng-container *transloco="let t; read: 'reading-list-detail'"> |   <ng-container *transloco="let t; read: 'reading-list-detail'"> | ||||||
|     <app-side-nav-companion-bar [hasExtras]="readingList !== undefined" [extraDrawer]="extrasDrawer"> |     <form [formGroup]="formGroup"> | ||||||
|       <h4 title> |  | ||||||
|         {{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> |  | ||||||
|       @if (readingList) { |       @if (readingList) { | ||||||
|           <div> |       <div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock> | ||||||
|             <div class="offcanvas-header"> |         <div class="row mb-0 mb-xl-3 info-container"> | ||||||
|               <h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4> |           <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"> | ||||||
|               <button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button> |             <app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)" (click)="read()"></app-image> | ||||||
|           </div> |           </div> | ||||||
|             <div class="offcanvas-body"> | 
 | ||||||
|               <div class="row g-0"> |           <div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12"> | ||||||
|                 <div class="col-md-12 col-sm-12 pe-2 mb-3"> |             <h4 class="title mb-2"> | ||||||
|                   <button class="btn btn-danger" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin"> |             <span>{{readingList.title}} | ||||||
|                     <span> |               @if (readingList.promoted) { | ||||||
|                         <i class="fa fa-check"></i> |                 (<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> | ||||||
|                     <span class="read-btn--text"> {{t('remove-read')}}</span> |  | ||||||
|                   </button> |  | ||||||
| 
 | 
 | ||||||
|                   @if (!(readingList.promoted && !this.isAdmin)) { |             </h4> | ||||||
|                     <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> |  | ||||||
| 
 | 
 | ||||||
|     @if (readingList) { | <!--            <app-metadata-detail-row [entity]="seriesMetadata"--> | ||||||
|       <div class="container-fluid mt-2"> | <!--                                     [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"> |             <div class="mt-3 mb-3"> | ||||||
|             <app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image> |               <div class="row g-0" style="align-items: center;"> | ||||||
|           </div> |  | ||||||
|           <div class="col-md-10 col-xs-8 col-sm-6 mt-2"> |  | ||||||
|             <div class="row g-0 mb-3"> |  | ||||||
|                 <div class="col-auto me-2"> |                 <div class="col-auto me-2"> | ||||||
|                   <!-- Action row--> |                   <!-- Action row--> | ||||||
|                   <div class="btn-group me-3"> |                   <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> |                       <span> | ||||||
|                         <i class="fa fa-book-open me-1" aria-hidden="true"></i> |                         <i class="fa fa-book-open me-1" aria-hidden="true"></i> | ||||||
|                         <span class="read-btn--text">{{t('continue')}}</span> |                         <span class="read-btn--text">{{t('continue')}}</span> | ||||||
|                       </span> |                       </span> | ||||||
|                     </button> |                     </button> | ||||||
|                     <div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')"> |                     <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> |                       <div class="dropdown-menu" ngbDropdownMenu> | ||||||
|                         <button ngbDropdownItem (click)="read()"> |                         <button ngbDropdownItem (click)="read()"> | ||||||
|                           <span> |                           <span> | ||||||
| @ -92,11 +74,41 @@ | |||||||
|                     </div> |                     </div> | ||||||
|                   </div> |                   </div> | ||||||
|                 </div> |                 </div> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                 @if (isOwnedReadingList) { | ||||||
|  |                   <div class="col-auto ms-2"> | ||||||
|  |                     <button class="btn btn-actions" (click)="editReadingList(readingList)" [ngbTooltip]="t('edit-alt')"> | ||||||
|  |                       <span><i class="fa fa-pen" aria-hidden="true"></i></span> | ||||||
|  |                     </button> | ||||||
|  |                   </div> | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                 <div class="col-auto ms-2 d-none d-md-block"> | ||||||
|  |                   <div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')"> | ||||||
|  |                     <app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables> | ||||||
|  |                   </div> | ||||||
|                 </div> |                 </div> | ||||||
| 
 | 
 | ||||||
|  |                 <div class="col-auto ms-2 d-none d-md-block btn-actions"> | ||||||
|  |                   <button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')"> | ||||||
|  |                     <i class="fa-solid fa-list-ol" aria-hidden="true"></i> | ||||||
|  |                   </button> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <div class="mt-2 mb-3"> | ||||||
|  |               <app-read-more [text]="readingList.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <div class="mt-2 upper-details"> | ||||||
|  |               <div class="row g-0"> | ||||||
|  |                 <div class="col-6 pe-5"> | ||||||
|  |                   <span class="fw-bold">{{t('date-range-title')}}</span> | ||||||
|  |                   <div> | ||||||
|                     @if (readingList.startingYear !== 0) { |                     @if (readingList.startingYear !== 0) { | ||||||
|               <div class="row g-0 mt-2"> |  | ||||||
|                 <h4 class="reading-list-years"> |  | ||||||
|                       @if (readingList.startingMonth > 0) { |                       @if (readingList.startingMonth > 0) { | ||||||
|                         {{(readingList.startingMonth +'/01/2020')| date:'MMM'}} |                         {{(readingList.startingMonth +'/01/2020')| date:'MMM'}} | ||||||
|                       } |                       } | ||||||
| @ -118,33 +130,90 @@ | |||||||
|                           {{readingList.endingYear}} |                           {{readingList.endingYear}} | ||||||
|                         } |                         } | ||||||
|                       } |                       } | ||||||
|                 </h4> |                     } @else { | ||||||
|               </div> |                       {{null | defaultValue}} | ||||||
|                     } |                     } | ||||||
| 
 |                   </div> | ||||||
| 
 |                 </div> | ||||||
|             <!-- Summary row--> |                 <div class="col-6"> | ||||||
|             <div class="row g-0 my-2"> |                   <span class="fw-bold">{{t('items-title')}}</span> | ||||||
|               <app-read-more [text]="readingListSummary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 170 : 200"></app-read-more> |                   <div> | ||||||
|  |                     {{t('item-count', {num: items.length | number})}} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             @if (characters$ | async; as characters) { |             <div class="mt-3 mb-2 upper-details"> | ||||||
|               @if (characters && characters.length > 0) { |               <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 mb-2"> | ||||||
|                     <div class="row"> |                     <div class="row"> | ||||||
|                     <h5>{{t('characters-title')}}</h5> |                       <app-badge-expander [items]="castInfo.writers" | ||||||
|                     <app-badge-expander [items]="characters"> |                                           [itemsTillExpander]="3" | ||||||
|                       <ng-template #badgeExpanderItem let-item let-position="idx"> |                                           [allowToggle]="false" | ||||||
|                         <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="goToCharacter(item)">{{item.name}}</a> |                                           (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> |                         </ng-template> | ||||||
|                       </app-badge-expander> |                       </app-badge-expander> | ||||||
|                     </div> |                     </div> | ||||||
|                   </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> |             </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> |                     <div class="row mb-1 scroll-container" #scrollingBlock> | ||||||
|                       @if (items.length === 0 && !isLoading) { |                       @if (items.length === 0 && !isLoading) { | ||||||
|                         <div class="mx-auto" style="width: 200px;"> |                         <div class="mx-auto" style="width: 200px;"> | ||||||
| @ -154,15 +223,44 @@ | |||||||
|                         <app-loading [loading]="isLoading"></app-loading> |                         <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" |                       <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"> |                         <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" |                           <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> |                         </ng-template> | ||||||
|                       </app-draggable-ordered-list> |                       </app-draggable-ordered-list> | ||||||
|                     </div> |                     </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> |       </div> | ||||||
|     } |     } | ||||||
|  |     </form> | ||||||
|   </ng-container> |   </ng-container> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -1,3 +1,6 @@ | |||||||
|  | @use '../../../../series-detail-common'; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| .main-container { | .main-container { | ||||||
|   margin-top: 10px; |   margin-top: 10px; | ||||||
|   padding: 0 0 0 10px; |   padding: 0 0 0 10px; | ||||||
|  | |||||||
| @ -1,12 +1,23 @@ | |||||||
| import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; | import { | ||||||
| import {ActivatedRoute, Router} from '@angular/router'; |   ChangeDetectionStrategy, | ||||||
|  |   ChangeDetectorRef, | ||||||
|  |   Component, | ||||||
|  |   DestroyRef, | ||||||
|  |   ElementRef, | ||||||
|  |   Inject, | ||||||
|  |   inject, | ||||||
|  |   OnInit, | ||||||
|  |   ViewChild | ||||||
|  | } from '@angular/core'; | ||||||
|  | import {ActivatedRoute, Router, RouterLink} from '@angular/router'; | ||||||
|  | import {AsyncPipe, DatePipe, DecimalPipe, DOCUMENT, Location, NgClass, NgStyle} from '@angular/common'; | ||||||
| import {ToastrService} from 'ngx-toastr'; | import {ToastrService} from 'ngx-toastr'; | ||||||
| import {take} from 'rxjs/operators'; | import {take} from 'rxjs/operators'; | ||||||
| import {ConfirmService} from 'src/app/shared/confirm.service'; | import {ConfirmService} from 'src/app/shared/confirm.service'; | ||||||
| import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; | import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; | ||||||
| import {LibraryType} from 'src/app/_models/library/library'; | import {LibraryType} from 'src/app/_models/library/library'; | ||||||
| import {MangaFormat} from 'src/app/_models/manga-format'; | import {MangaFormat} from 'src/app/_models/manga-format'; | ||||||
| import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list'; | import {ReadingList, ReadingListInfo, ReadingListItem} from 'src/app/_models/reading-list'; | ||||||
| import {AccountService} from 'src/app/_services/account.service'; | import {AccountService} from 'src/app/_services/account.service'; | ||||||
| import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; | import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; | ||||||
| import {ActionService} from 'src/app/_services/action.service'; | import {ActionService} from 'src/app/_services/action.service'; | ||||||
| @ -16,57 +27,84 @@ import { | |||||||
|   DraggableOrderedListComponent, |   DraggableOrderedListComponent, | ||||||
|   IndexUpdateEvent |   IndexUpdateEvent | ||||||
| } from '../draggable-ordered-list/draggable-ordered-list.component'; | } from '../draggable-ordered-list/draggable-ordered-list.component'; | ||||||
| import {forkJoin, Observable} from 'rxjs'; | import {forkJoin, startWith, tap} from 'rxjs'; | ||||||
| import {ReaderService} from 'src/app/_services/reader.service'; | import {ReaderService} from 'src/app/_services/reader.service'; | ||||||
| import {LibraryService} from 'src/app/_services/library.service'; | import {LibraryService} from 'src/app/_services/library.service'; | ||||||
| import {Person} from 'src/app/_models/metadata/person'; |  | ||||||
| import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component'; | import {ReadingListItemComponent} from '../reading-list-item/reading-list-item.component'; | ||||||
| import {LoadingComponent} from '../../../shared/loading/loading.component'; | import {LoadingComponent} from '../../../shared/loading/loading.component'; | ||||||
| import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component'; | import {BadgeExpanderComponent} from '../../../shared/badge-expander/badge-expander.component'; | ||||||
| import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; | import {ReadMoreComponent} from '../../../shared/read-more/read-more.component'; | ||||||
| import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; |  | ||||||
| import {ImageComponent} from '../../../shared/image/image.component'; |  | ||||||
| import {AsyncPipe, DatePipe, DecimalPipe, NgClass} from '@angular/common'; |  | ||||||
| import { | import { | ||||||
|   SideNavCompanionBarComponent |   NgbDropdown, | ||||||
| } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; |   NgbDropdownItem, | ||||||
|  |   NgbDropdownMenu, | ||||||
|  |   NgbDropdownToggle, | ||||||
|  |   NgbNav, | ||||||
|  |   NgbNavChangeEvent, | ||||||
|  |   NgbNavContent, | ||||||
|  |   NgbNavItem, | ||||||
|  |   NgbNavLink, | ||||||
|  |   NgbNavOutlet, | ||||||
|  |   NgbTooltip | ||||||
|  | } from '@ng-bootstrap/ng-bootstrap'; | ||||||
|  | import {ImageComponent} from '../../../shared/image/image.component'; | ||||||
| import {translate, TranslocoDirective} from "@jsverse/transloco"; | import {translate, TranslocoDirective} from "@jsverse/transloco"; | ||||||
| import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; | import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; | ||||||
| import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; |  | ||||||
| import {FilterField} from "../../../_models/metadata/v2/filter-field"; |  | ||||||
| import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; |  | ||||||
| import {Title} from "@angular/platform-browser"; | import {Title} from "@angular/platform-browser"; | ||||||
|  | import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; | ||||||
|  | import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; | ||||||
|  | import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component"; | ||||||
|  | import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; | ||||||
|  | import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; | ||||||
|  | import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component"; | ||||||
|  | import {IHasCast} from "../../../_models/common/i-has-cast"; | ||||||
|  | 
 | ||||||
|  | enum TabID { | ||||||
|  |   Storyline = 'storyline-tab', | ||||||
|  |   Volumes = 'volume-tab', | ||||||
|  |   Details = 'details-tab', | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'app-reading-list-detail', |   selector: 'app-reading-list-detail', | ||||||
|   templateUrl: './reading-list-detail.component.html', |   templateUrl: './reading-list-detail.component.html', | ||||||
|   styleUrls: ['./reading-list-detail.component.scss'], |   styleUrls: ['./reading-list-detail.component.scss'], | ||||||
|   changeDetection: ChangeDetectionStrategy.OnPush, |   changeDetection: ChangeDetectionStrategy.OnPush, | ||||||
|   imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, NgbDropdown, |   imports: [CardActionablesComponent, ImageComponent, NgbDropdown, | ||||||
|     NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, |     NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, | ||||||
|     LoadingComponent, DraggableOrderedListComponent, |     LoadingComponent, DraggableOrderedListComponent, | ||||||
|     ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective] |     ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReactiveFormsModule, | ||||||
|  |     NgbNav, NgbNavContent, NgbNavLink, NgbTooltip, | ||||||
|  |     RouterLink, VirtualScrollerModule, NgStyle, NgbNavOutlet, NgbNavItem, PromotedIconComponent, DefaultValuePipe, DetailsTabComponent] | ||||||
| }) | }) | ||||||
| export class ReadingListDetailComponent implements OnInit { | export class ReadingListDetailComponent implements OnInit { | ||||||
| 
 | 
 | ||||||
|  |   protected readonly MangaFormat = MangaFormat; | ||||||
|  |   protected readonly Breakpoint = Breakpoint; | ||||||
|  |   protected readonly TabID = TabID; | ||||||
|  |   protected readonly encodeURIComponent = encodeURIComponent; | ||||||
|  | 
 | ||||||
|   private route = inject(ActivatedRoute); |   private route = inject(ActivatedRoute); | ||||||
|   private router = inject(Router); |   private router = inject(Router); | ||||||
|   private readingListService = inject(ReadingListService); |   private readingListService = inject(ReadingListService); | ||||||
|   private actionService = inject(ActionService); |   private actionService = inject(ActionService); | ||||||
|   private actionFactoryService = inject(ActionFactoryService); |   private actionFactoryService = inject(ActionFactoryService); | ||||||
|   public utilityService = inject(UtilityService); |   protected utilityService = inject(UtilityService); | ||||||
|   public imageService = inject(ImageService); |   protected imageService = inject(ImageService); | ||||||
|   private accountService = inject(AccountService); |   private accountService = inject(AccountService); | ||||||
|   private toastr = inject(ToastrService); |   private toastr = inject(ToastrService); | ||||||
|   private confirmService = inject(ConfirmService); |   private confirmService = inject(ConfirmService); | ||||||
|   private libraryService = inject(LibraryService); |   private libraryService = inject(LibraryService); | ||||||
|   private readerService = inject(ReaderService); |   private readerService = inject(ReaderService); | ||||||
|   private cdRef = inject(ChangeDetectorRef); |   private cdRef = inject(ChangeDetectorRef); | ||||||
|   private filterUtilityService = inject(FilterUtilitiesService); |  | ||||||
|   private titleService = inject(Title); |   private titleService = inject(Title); | ||||||
|  |   private location = inject(Location); | ||||||
|  |   private destroyRef = inject(DestroyRef); | ||||||
| 
 | 
 | ||||||
|   protected readonly MangaFormat = MangaFormat; | 
 | ||||||
|   protected readonly Breakpoint = Breakpoint; | 
 | ||||||
|  |   @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; | ||||||
|  |   @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; | ||||||
| 
 | 
 | ||||||
|   items: Array<ReadingListItem> = []; |   items: Array<ReadingListItem> = []; | ||||||
|   listId!: number; |   listId!: number; | ||||||
| @ -75,12 +113,62 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|   isAdmin: boolean = false; |   isAdmin: boolean = false; | ||||||
|   isLoading: boolean = false; |   isLoading: boolean = false; | ||||||
|   accessibilityMode: boolean = false; |   accessibilityMode: boolean = false; | ||||||
|  |   editMode: boolean = false; | ||||||
|   readingListSummary: string = ''; |   readingListSummary: string = ''; | ||||||
| 
 | 
 | ||||||
|   libraryTypes: {[key: number]: LibraryType} = {}; |   libraryTypes: {[key: number]: LibraryType} = {}; | ||||||
|   characters$!: Observable<Person[]>; |   activeTabId = TabID.Storyline; | ||||||
|  |   showStorylineTab = true; | ||||||
|  |   isOwnedReadingList: boolean = false; | ||||||
|  |   rlInfo: ReadingListInfo | null = null; | ||||||
|  |   castInfo: IHasCast = { | ||||||
|  |     characterLocked: false, | ||||||
|  |     characters: [], | ||||||
|  |     coloristLocked: false, | ||||||
|  |     colorists: [], | ||||||
|  |     coverArtistLocked: false, | ||||||
|  |     coverArtists: [], | ||||||
|  |     editorLocked: false, | ||||||
|  |     editors: [], | ||||||
|  |     imprintLocked: false, | ||||||
|  |     imprints: [], | ||||||
|  |     inkerLocked: false, | ||||||
|  |     inkers: [], | ||||||
|  |     languageLocked: false, | ||||||
|  |     lettererLocked: false, | ||||||
|  |     letterers: [], | ||||||
|  |     locationLocked: false, | ||||||
|  |     locations: [], | ||||||
|  |     pencillerLocked: false, | ||||||
|  |     pencillers: [], | ||||||
|  |     publisherLocked: false, | ||||||
|  |     publishers: [], | ||||||
|  |     teamLocked: false, | ||||||
|  |     teams: [], | ||||||
|  |     translatorLocked: false, | ||||||
|  |     translators: [], | ||||||
|  |     writerLocked: false, | ||||||
|  |     writers: [] | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   formGroup = new FormGroup({ | ||||||
|  |     'edit': new FormControl(false, []), | ||||||
|  |     'accessibilityMode': new FormControl(false, []), | ||||||
|  |   }); | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |   get ScrollingBlockHeight() { | ||||||
|  |     if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)'; | ||||||
|  |     const navbar = this.document.querySelector('.navbar') as HTMLElement; | ||||||
|  |     if (navbar === null) return 'calc(var(--vh)*100)'; | ||||||
|  | 
 | ||||||
|  |     const companionHeight = this.companionBar?.nativeElement.offsetHeight || 0; | ||||||
|  |     const navbarHeight = navbar.offsetHeight; | ||||||
|  |     const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding
 | ||||||
|  |     return 'calc(var(--vh)*100 - ' + totalHeight + 'px)'; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   constructor(@Inject(DOCUMENT) private document: Document) {} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|   ngOnInit(): void { |   ngOnInit(): void { | ||||||
| @ -92,9 +180,45 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     this.listId = parseInt(listId, 10); |     this.listId = parseInt(listId, 10); | ||||||
|     this.characters$ = this.readingListService.getCharacters(this.listId); | 
 | ||||||
|  | 
 | ||||||
|  |     this.readingListService.getAllPeople(this.listId).subscribe(allPeople => { | ||||||
|  |       this.castInfo = allPeople; | ||||||
|  |       this.cdRef.markForCheck(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     this.readingListService.getReadingListInfo(this.listId).subscribe(info => { | ||||||
|  |       this.rlInfo = info; | ||||||
|  |       this.cdRef.markForCheck(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     this.formGroup.get('edit')!.valueChanges.pipe( | ||||||
|  |       takeUntilDestroyed(this.destroyRef), | ||||||
|  |       startWith(false), | ||||||
|  |       tap(mode => { | ||||||
|  |         this.editMode = (mode || false); | ||||||
|  |         this.cdRef.markForCheck(); | ||||||
|  |       }) | ||||||
|  |     ).subscribe(); | ||||||
|  | 
 | ||||||
|  |     this.formGroup.get('accessibilityMode')!.valueChanges.pipe( | ||||||
|  |       takeUntilDestroyed(this.destroyRef), | ||||||
|  |       startWith(this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet), | ||||||
|  |       tap(mode => { | ||||||
|  |         this.accessibilityMode = (mode || this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet); | ||||||
|  |         this.cdRef.markForCheck(); | ||||||
|  |       }) | ||||||
|  |     ).subscribe(); | ||||||
|  | 
 | ||||||
|  |     if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) { | ||||||
|  |       this.formGroup.get('accessibilityMode')?.disable(); | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet; |     this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet; | ||||||
|  |     this.editMode = false; | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
| 
 | 
 | ||||||
|     forkJoin([ |     forkJoin([ | ||||||
| @ -104,7 +228,7 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|       const libraries = results[0]; |       const libraries = results[0]; | ||||||
|       const readingList = results[1]; |       const readingList = results[1]; | ||||||
| 
 | 
 | ||||||
|       this.titleService.setTitle('Kavita - ' + readingList.title); | 
 | ||||||
| 
 | 
 | ||||||
|       libraries.forEach(lib => { |       libraries.forEach(lib => { | ||||||
|         this.libraryTypes[lib.id] = lib.type; |         this.libraryTypes[lib.id] = lib.type; | ||||||
| @ -116,8 +240,10 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|         this.router.navigateByUrl('library'); |         this.router.navigateByUrl('library'); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       this.readingList = readingList; |       this.readingList = readingList; | ||||||
|       this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>'); |       this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>'); | ||||||
|  |       this.titleService.setTitle('Kavita - ' + readingList.title); | ||||||
| 
 | 
 | ||||||
|       this.cdRef.markForCheck(); |       this.cdRef.markForCheck(); | ||||||
| 
 | 
 | ||||||
| @ -127,10 +253,12 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|           this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) |           this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) | ||||||
|             .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); |             .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); | ||||||
|  |           this.isOwnedReadingList = this.actions.filter(a => a.action === Action.Edit).length > 0; | ||||||
|           this.cdRef.markForCheck(); |           this.cdRef.markForCheck(); | ||||||
|         } |         } | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|     this.getListItems(); |     this.getListItems(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -163,14 +291,7 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|         await this.deleteList(readingList); |         await this.deleteList(readingList); | ||||||
|         break; |         break; | ||||||
|       case Action.Edit: |       case Action.Edit: | ||||||
|         this.actionService.editReadingList(readingList, (readingList: ReadingList) => { |         this.editReadingList(readingList); | ||||||
|           // Reload information around list
 |  | ||||||
|           this.readingListService.getReadingList(this.listId).subscribe(rl => { |  | ||||||
|             this.readingList = rl; |  | ||||||
|             this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>'); |  | ||||||
|             this.cdRef.markForCheck(); |  | ||||||
|           }); |  | ||||||
|         }); |  | ||||||
|         break; |         break; | ||||||
|       case Action.Promote: |       case Action.Promote: | ||||||
|         this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => { |         this.actionService.promoteMultipleReadingLists([this.readingList!], true, () => { | ||||||
| @ -191,6 +312,17 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   editReadingList(readingList: ReadingList) { | ||||||
|  |     this.actionService.editReadingList(readingList, (readingList: ReadingList) => { | ||||||
|  |       // Reload information around list
 | ||||||
|  |       this.readingListService.getReadingList(this.listId).subscribe(rl => { | ||||||
|  |         this.readingList = rl!; | ||||||
|  |         this.readingListSummary = (this.readingList.summary === null ? '' : this.readingList.summary).replace(/\n/g, '<br>'); | ||||||
|  |         this.cdRef.markForCheck(); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async deleteList(readingList: ReadingList) { |   async deleteList(readingList: ReadingList) { | ||||||
|     if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; |     if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; | ||||||
| 
 | 
 | ||||||
| @ -260,7 +392,32 @@ export class ReadingListDetailComponent implements OnInit { | |||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   goToCharacter(character: Person) { | 
 | ||||||
|     this.filterUtilityService.applyFilter(['all-series'], FilterField.Characters, FilterComparison.Contains, character.id + '').subscribe(); |   toggleReorder() { | ||||||
|  |     this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value); | ||||||
|  |     this.cdRef.markForCheck(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   onNavChange(event: NgbNavChangeEvent) { | ||||||
|  |     this.updateUrl(event.nextId); | ||||||
|  |     this.cdRef.markForCheck(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private updateUrl(activeTab: TabID) { | ||||||
|  |     const tokens = this.location.path().split('#'); | ||||||
|  |     const newUrl = `${tokens[0]}#${activeTab}`; | ||||||
|  |     this.location.replaceState(newUrl) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   switchTabsToDetail() { | ||||||
|  |     this.activeTabId = TabID.Details; | ||||||
|  |     this.cdRef.markForCheck(); | ||||||
|  |     setTimeout(() => { | ||||||
|  |       const tabElem = this.document.querySelector('#details-tab'); | ||||||
|  |       if (tabElem) { | ||||||
|  |         (tabElem as HTMLLIElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); | ||||||
|  |       } | ||||||
|  |     }, 10); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -17,18 +17,23 @@ | |||||||
|         <h5 class="mb-1 pb-0" id="item.id--{{position}}"> |         <h5 class="mb-1 pb-0" id="item.id--{{position}}"> | ||||||
|           {{item.title}} |           {{item.title}} | ||||||
|           <div class="actions float-end"> |           <div class="actions float-end"> | ||||||
|  |             @if (showRemove) { | ||||||
|               <button class="btn btn-danger" (click)="remove.emit(item)"> |               <button class="btn btn-danger" (click)="remove.emit(item)"> | ||||||
|               <span> |               <span> | ||||||
|                 <i class="fa fa-trash me-1" aria-hidden="true"></i> |                 <i class="fa fa-trash me-1" aria-hidden="true"></i> | ||||||
|               </span> |               </span> | ||||||
|                 <span class="d-none d-md-inline-block">{{t('remove')}}</span> |                 <span class="d-none d-md-inline-block">{{t('remove')}}</span> | ||||||
|               </button> |               </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> |               <span> | ||||||
|                 <i class="fa fa-book me-1" aria-hidden="true"></i> |                 <i class="fa fa-book me-1" aria-hidden="true"></i> | ||||||
|               </span> |               </span> | ||||||
|                 <span class="d-none d-md-inline-block">{{t('read')}}</span> |                 <span class="d-none d-md-inline-block">{{t('read')}}</span> | ||||||
|               </button> |               </button> | ||||||
|  |             } | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|         </h5> |         </h5> | ||||||
|  | |||||||
| @ -24,6 +24,8 @@ export class ReadingListItemComponent { | |||||||
| 
 | 
 | ||||||
|   @Input({required: true}) item!: ReadingListItem; |   @Input({required: true}) item!: ReadingListItem; | ||||||
|   @Input() position: number = 0; |   @Input() position: number = 0; | ||||||
|  |   @Input() showRemove: boolean = false; | ||||||
|  |   @Input() showRead: boolean = true; | ||||||
|   @Input() libraryTypes: {[key: number]: LibraryType} = {}; |   @Input() libraryTypes: {[key: number]: LibraryType} = {}; | ||||||
|   /** |   /** | ||||||
|    * If the Reading List is promoted or not |    * If the Reading List is promoted or not | ||||||
|  | |||||||
| @ -89,7 +89,7 @@ | |||||||
| 
 | 
 | ||||||
|               @if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) { |               @if ((licenseService.hasValidLicense$ | async) && libraryAllowsScrobbling) { | ||||||
|                 <div class="col-auto ms-2"> |                 <div class="col-auto ms-2"> | ||||||
|                   <button class="btn btn-actions" (click)="toggleScrobbling($event)" [ngbTooltip]="t('scrobbling-tooltip')"> |                   <button class="btn btn-actions" (click)="toggleScrobbling($event)" [ngbTooltip]="t('scrobbling-tooltip', {value: isScrobbling ? t('on') : t('off')})"> | ||||||
|                     <i class="fa-solid fa-tower-{{(isScrobbling) ? 'broadcast' : 'observation'}}" aria-hidden="true"></i> |                     <i class="fa-solid fa-tower-{{(isScrobbling) ? 'broadcast' : 'observation'}}" aria-hidden="true"></i> | ||||||
|                   </button> |                   </button> | ||||||
|                 </div> |                 </div> | ||||||
|  | |||||||
| @ -150,6 +150,16 @@ interface StoryLineItem { | |||||||
| }) | }) | ||||||
| export class SeriesDetailComponent implements OnInit, AfterContentChecked { | export class SeriesDetailComponent implements OnInit, AfterContentChecked { | ||||||
| 
 | 
 | ||||||
|  |   protected readonly LibraryType = LibraryType; | ||||||
|  |   protected readonly TabID = TabID; | ||||||
|  |   protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber; | ||||||
|  |   protected readonly SpecialVolumeNumber = SpecialVolumeNumber; | ||||||
|  |   protected readonly SettingsTabId = SettingsTabId; | ||||||
|  |   protected readonly FilterField = FilterField; | ||||||
|  |   protected readonly AgeRating = AgeRating; | ||||||
|  |   protected readonly Breakpoint = Breakpoint; | ||||||
|  |   protected readonly encodeURIComponent = encodeURIComponent; | ||||||
|  | 
 | ||||||
|   private readonly destroyRef = inject(DestroyRef); |   private readonly destroyRef = inject(DestroyRef); | ||||||
|   private readonly route = inject(ActivatedRoute); |   private readonly route = inject(ActivatedRoute); | ||||||
|   private readonly seriesService = inject(SeriesService); |   private readonly seriesService = inject(SeriesService); | ||||||
| @ -180,14 +190,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { | |||||||
|   private readonly scrobbleService = inject(ScrobblingService); |   private readonly scrobbleService = inject(ScrobblingService); | ||||||
|   private readonly location = inject(Location); |   private readonly location = inject(Location); | ||||||
| 
 | 
 | ||||||
|   protected readonly LibraryType = LibraryType; |  | ||||||
|   protected readonly TabID = TabID; |  | ||||||
|   protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber; |  | ||||||
|   protected readonly SpecialVolumeNumber = SpecialVolumeNumber; |  | ||||||
|   protected readonly SettingsTabId = SettingsTabId; |  | ||||||
|   protected readonly FilterField = FilterField; |  | ||||||
|   protected readonly AgeRating = AgeRating; |  | ||||||
|   protected readonly Breakpoint = Breakpoint; |  | ||||||
| 
 | 
 | ||||||
|   @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; |   @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; | ||||||
|   @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; |   @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; | ||||||
| @ -1212,6 +1214,4 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { | |||||||
|       } |       } | ||||||
|     }, 10); |     }, 10); | ||||||
|   } |   } | ||||||
| 
 |  | ||||||
|     protected readonly encodeURIComponent = encodeURIComponent; |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ | |||||||
|       </div> |       </div> | ||||||
|       <div class="col-auto text-end align-self-end justify-content-end edit-btn"> |       <div class="col-auto text-end align-self-end justify-content-end edit-btn"> | ||||||
|         @if (showEdit) { |         @if (showEdit) { | ||||||
|         <button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit"> |         <button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit"> | ||||||
|           {{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}} |           {{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}} | ||||||
|         </button> |         </button> | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ | |||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div class="col-auto text-end align-self-end justify-content-end edit-btn"> |       <div class="col-auto text-end align-self-end justify-content-end edit-btn"> | ||||||
|         <button type="button" class="btn btn-text btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button> |         <button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  | |||||||
| @ -1,11 +1,16 @@ | |||||||
| <form [formGroup]="form" *transloco="let t"> | <form [formGroup]="form" *transloco="let t"> | ||||||
| 
 |   <div formArrayName="items"> | ||||||
|   @for(item of Items; let i = $index; track item; let isFirst = $first) { |     @for(item of ItemsArray.controls; let i = $index; track i) { | ||||||
|       <div class="row g-0 mb-3"> |       <div class="row g-0 mb-3"> | ||||||
|         <div class="col-lg-10 col-md-12 pe-2"> |         <div class="col-lg-10 col-md-12 pe-2"> | ||||||
|           <div class="mb-3"> |           <div class="mb-3"> | ||||||
|             <label for="item--{{i}}" class="visually-hidden">{{label}}</label> |             <label for="item--{{i}}" class="visually-hidden">{{label}}</label> | ||||||
|           <input type="text" class="form-control" formControlName="link{{i}}" id="item--{{i}}"> |             <input | ||||||
|  |               type="text" | ||||||
|  |               class="form-control" | ||||||
|  |               [formControlName]="i" | ||||||
|  |               id="item--{{i}}" | ||||||
|  |             > | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div class="col-lg-2"> |         <div class="col-lg-2"> | ||||||
| @ -13,12 +18,16 @@ | |||||||
|             <i class="fa-solid fa-plus" aria-hidden="true"></i> |             <i class="fa-solid fa-plus" aria-hidden="true"></i> | ||||||
|             <span class="visually-hidden">{{t('common.add')}}</span> |             <span class="visually-hidden">{{t('common.add')}}</span> | ||||||
|           </button> |           </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> |             <i class="fa-solid fa-xmark" aria-hidden="true"></i> | ||||||
|             <span class="visually-hidden">{{t('common.remove')}}</span> |             <span class="visually-hidden">{{t('common.remove')}}</span> | ||||||
|           </button> |           </button> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     } |     } | ||||||
| 
 |   </div> | ||||||
| </form> | </form> | ||||||
|  | |||||||
| @ -9,15 +9,14 @@ import { | |||||||
|   OnInit, |   OnInit, | ||||||
|   Output |   Output | ||||||
| } from '@angular/core'; | } from '@angular/core'; | ||||||
| import {CommonModule} from '@angular/common'; | import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; | ||||||
| import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; |  | ||||||
| import {TranslocoDirective} from "@jsverse/transloco"; | import {TranslocoDirective} from "@jsverse/transloco"; | ||||||
| import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; | import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; | ||||||
| import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; | import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; | ||||||
| 
 | 
 | ||||||
| @Component({ | @Component({ | ||||||
|     selector: 'app-edit-list', |     selector: 'app-edit-list', | ||||||
|     imports: [CommonModule, ReactiveFormsModule, TranslocoDirective], |     imports: [ReactiveFormsModule, TranslocoDirective], | ||||||
|     templateUrl: './edit-list.component.html', |     templateUrl: './edit-list.component.html', | ||||||
|     styleUrl: './edit-list.component.scss', |     styleUrl: './edit-list.component.scss', | ||||||
|     changeDetection: ChangeDetectionStrategy.OnPush |     changeDetection: ChangeDetectionStrategy.OnPush | ||||||
| @ -31,20 +30,16 @@ export class EditListComponent implements OnInit { | |||||||
|   @Input({required: true}) label = ''; |   @Input({required: true}) label = ''; | ||||||
|   @Output() updateItems = new EventEmitter<Array<string>>(); |   @Output() updateItems = new EventEmitter<Array<string>>(); | ||||||
| 
 | 
 | ||||||
|   form: FormGroup = new FormGroup({}); |   form: FormGroup = new FormGroup({items: new FormArray([])}); | ||||||
|   private combinedItems: string = ''; |  | ||||||
| 
 | 
 | ||||||
|   get Items() { |   get ItemsArray(): FormArray { | ||||||
|     return this.combinedItems.split(',') || ['']; |     return this.form.get('items') as FormArray; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|   ngOnInit() { |   ngOnInit() { | ||||||
|     this.items.forEach((link, index) => { |     this.items.forEach(item => this.addItem(item)); | ||||||
|       this.form.addControl('link' + index, new FormControl(link, [])); |  | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     this.combinedItems = this.items.join(','); |  | ||||||
| 
 | 
 | ||||||
|     this.form.valueChanges.pipe( |     this.form.valueChanges.pipe( | ||||||
|       debounceTime(100), |       debounceTime(100), | ||||||
| @ -55,47 +50,39 @@ export class EditListComponent implements OnInit { | |||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   createItemControl(value: string = ''): FormControl { | ||||||
|  |     return new FormControl(value, []); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   add() { |   add() { | ||||||
|     this.combinedItems += ','; |     this.ItemsArray.push(this.createItemControl()); | ||||||
|     this.form.addControl('link' + (this.Items.length - 1), new FormControl('', [])); |  | ||||||
|     this.emit(); |     this.emit(); | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   addItem(value: string) { | ||||||
|  |     this.ItemsArray.push(this.createItemControl(value)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   remove(index: number) { |   remove(index: number) { | ||||||
| 
 |     // If it's the last item, just clear its value
 | ||||||
|     const initialControls = Object.keys(this.form.controls) |     if (this.ItemsArray.length === 1) { | ||||||
|       .filter(key => key.startsWith('link')); |       this.ItemsArray.at(0).setValue(''); | ||||||
| 
 |  | ||||||
|     if (index == 0 && initialControls.length === 1) { |  | ||||||
|       this.form.get(initialControls[0])?.setValue('', {emitEvent: true}); |  | ||||||
|       this.emit(); |       this.emit(); | ||||||
|       this.cdRef.markForCheck(); |  | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Remove the form control explicitly then rebuild the combinedItems
 |     this.ItemsArray.removeAt(index); | ||||||
|     this.form.removeControl('link' + index, {emitEvent: true}); |  | ||||||
| 
 |  | ||||||
|     this.combinedItems = Object.keys(this.form.controls) |  | ||||||
|       .filter(key => key.startsWith('link')) |  | ||||||
|       .map(key => this.form.get(key)?.value) |  | ||||||
|       .join(','); |  | ||||||
| 
 |  | ||||||
|     // Recreate form to ensure index's match
 |  | ||||||
|     this.form = new FormGroup({}); |  | ||||||
|     this.Items.forEach((item, index) => { |  | ||||||
|       this.form.addControl('link' + index, new FormControl(item, [])); |  | ||||||
|     }) |  | ||||||
| 
 |  | ||||||
|     this.emit(); |     this.emit(); | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // Emit non-empty item values
 | ||||||
|   emit() { |   emit() { | ||||||
|     this.updateItems.emit(Object.keys(this.form.controls) |     const nonEmptyItems = this.ItemsArray.controls | ||||||
|     .filter(key => key.startsWith('link')) |       .map(control => control.value) | ||||||
|     .map(key => this.form.get(key)?.value) |       .filter(value => value !== null && value.trim() !== ''); | ||||||
|     .filter(v => v !== null && v !== '')); | 
 | ||||||
|  |     this.updateItems.emit(nonEmptyItems); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -54,7 +54,17 @@ export class ReadMoreComponent implements OnChanges { | |||||||
|     this.hideToggle = false; |     this.hideToggle = false; | ||||||
|     if (this.isCollapsed) { |     if (this.isCollapsed) { | ||||||
|       this.currentText = text.substring(0, this.maxLength); |       this.currentText = text.substring(0, this.maxLength); | ||||||
|       this.currentText = this.currentText.substring(0, Math.min(this.currentText.length, this.currentText.lastIndexOf(' '))); | 
 | ||||||
|  |       // Find last natural breaking point: space for English, or a CJK character boundary
 | ||||||
|  |       const match = this.currentText.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+$/u); | ||||||
|  |       const lastSpace = this.currentText.lastIndexOf(' '); | ||||||
|  | 
 | ||||||
|  |       if (lastSpace > 0) { | ||||||
|  |         this.currentText = this.currentText.substring(0, lastSpace); // Prefer space for English
 | ||||||
|  |       } else if (match) { | ||||||
|  |         this.currentText = this.currentText.substring(0, this.currentText.length - match[0].length); // Trim CJK
 | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       this.currentText = this.currentText + '…'; |       this.currentText = this.currentText + '…'; | ||||||
|     } else if (!this.isCollapsed)  { |     } else if (!this.isCollapsed)  { | ||||||
|       this.currentText = text; |       this.currentText = text; | ||||||
| @ -62,6 +72,7 @@ export class ReadMoreComponent implements OnChanges { | |||||||
| 
 | 
 | ||||||
|     this.cdRef.markForCheck(); |     this.cdRef.markForCheck(); | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|   ngOnChanges() { |   ngOnChanges() { | ||||||
|       this.determineView(); |       this.determineView(); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,12 +1,4 @@ | |||||||
| import { | import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; | ||||||
|   ChangeDetectionStrategy, |  | ||||||
|   ChangeDetectorRef, |  | ||||||
|   Component, |  | ||||||
|   DestroyRef, |  | ||||||
|   inject, |  | ||||||
|   Input, |  | ||||||
|   OnInit |  | ||||||
| } from '@angular/core'; |  | ||||||
| import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; | import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; | ||||||
| import { | import { | ||||||
|   NgbActiveModal, |   NgbActiveModal, | ||||||
| @ -244,12 +236,16 @@ export class LibrarySettingsModalComponent implements OnInit { | |||||||
| 
 | 
 | ||||||
|       this.madeChanges = false; |       this.madeChanges = false; | ||||||
| 
 | 
 | ||||||
|  |       // TODO: Refactor into FormArray
 | ||||||
|       for(let fileTypeGroup of allFileTypeGroup) { |       for(let fileTypeGroup of allFileTypeGroup) { | ||||||
|         this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); |         this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|  |       // TODO: Refactor into FormArray
 | ||||||
|       for(let glob of this.library.excludePatterns) { |       for(let glob of this.library.excludePatterns) { | ||||||
|         this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, [])); |         this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, [])); | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       this.excludePatterns = this.library.excludePatterns; |       this.excludePatterns = this.library.excludePatterns; | ||||||
|     } else { |     } else { | ||||||
|       for(let fileTypeGroup of allFileTypeGroup) { |       for(let fileTypeGroup of allFileTypeGroup) { | ||||||
|  | |||||||
| @ -264,6 +264,18 @@ | |||||||
|             </ng-template> |             </ng-template> | ||||||
|           </app-setting-switch> |           </app-setting-switch> | ||||||
|         </div> |         </div> | ||||||
|  | 
 | ||||||
|  |         <div class="row g-0 mt-4 mb-4"> | ||||||
|  |           <app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')"> | ||||||
|  |             <ng-template #switch> | ||||||
|  |               <div class="form-check form-switch float-end"> | ||||||
|  |                 <input type="checkbox" role="switch" | ||||||
|  |                        formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input" | ||||||
|  |                        aria-labelledby="auto-close-label"> | ||||||
|  |               </div> | ||||||
|  |             </ng-template> | ||||||
|  |           </app-setting-switch> | ||||||
|  |         </div> | ||||||
|       </ng-container> |       </ng-container> | ||||||
| 
 | 
 | ||||||
|       <div class="setting-section-break"></div> |       <div class="setting-section-break"></div> | ||||||
|  | |||||||
| @ -110,7 +110,7 @@ export class ManageUserPreferencesComponent implements OnInit { | |||||||
|   get Locale() { |   get Locale() { | ||||||
|     if (!this.settingsForm.get('locale')) return 'English'; |     if (!this.settingsForm.get('locale')) return 'English'; | ||||||
| 
 | 
 | ||||||
|     return this.locales.filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName; |     return (this.locales || []).filter(l => l.fileName === this.settingsForm.get('locale')!.value)[0].renderName; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -154,6 +154,7 @@ export class ManageUserPreferencesComponent implements OnInit { | |||||||
|       this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); |       this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); | ||||||
|       this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, [])); |       this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, [])); | ||||||
|       this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, [])); |       this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, [])); | ||||||
|  |       this.settingsForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.user.preferences.allowAutomaticWebtoonReaderDetection, [])); | ||||||
| 
 | 
 | ||||||
|       this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); |       this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); | ||||||
|       this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); |       this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); | ||||||
| @ -226,6 +227,7 @@ export class ManageUserPreferencesComponent implements OnInit { | |||||||
|     this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false}); |     this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook, {onlySelf: true, emitEvent: false}); | ||||||
|     this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false}); |     this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate, {onlySelf: true, emitEvent: false}); | ||||||
|     this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false}); |     this.settingsForm.get('backgroundColor')?.setValue(this.user.preferences.backgroundColor, {onlySelf: true, emitEvent: false}); | ||||||
|  |     this.settingsForm.get('allowAutomaticWebtoonReaderDetection')?.setValue(this.user.preferences.allowAutomaticWebtoonReaderDetection, {onlySelf: true, emitEvent: false}); | ||||||
| 
 | 
 | ||||||
|     this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false}); |     this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily, {onlySelf: true, emitEvent: false}); | ||||||
|     this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false}); |     this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize, {onlySelf: true, emitEvent: false}); | ||||||
| @ -265,6 +267,7 @@ export class ManageUserPreferencesComponent implements OnInit { | |||||||
|       readerMode: parseInt(modelSettings.readerMode, 10), |       readerMode: parseInt(modelSettings.readerMode, 10), | ||||||
|       layoutMode: parseInt(modelSettings.layoutMode, 10), |       layoutMode: parseInt(modelSettings.layoutMode, 10), | ||||||
|       showScreenHints: modelSettings.showScreenHints, |       showScreenHints: modelSettings.showScreenHints, | ||||||
|  |       allowAutomaticWebtoonReaderDetection: modelSettings.allowAutomaticWebtoonReaderDetection, | ||||||
|       backgroundColor: modelSettings.backgroundColor || '#000', |       backgroundColor: modelSettings.backgroundColor || '#000', | ||||||
|       bookReaderFontFamily: modelSettings.bookReaderFontFamily, |       bookReaderFontFamily: modelSettings.bookReaderFontFamily, | ||||||
|       bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, |       bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, | ||||||
|  | |||||||
| @ -156,6 +156,8 @@ | |||||||
|         "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", |         "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", | ||||||
|         "swipe-to-paginate-label": "Swipe to Paginate", |         "swipe-to-paginate-label": "Swipe to Paginate", | ||||||
|         "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", |         "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", | ||||||
|  |         "allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode", | ||||||
|  |         "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", | ||||||
| 
 | 
 | ||||||
|         "book-reader-settings-title": "Book Reader", |         "book-reader-settings-title": "Book Reader", | ||||||
|         "tap-to-paginate-label": "Tap to Paginate", |         "tap-to-paginate-label": "Tap to Paginate", | ||||||
| @ -973,9 +975,11 @@ | |||||||
|         "more-alt": "More", |         "more-alt": "More", | ||||||
|         "time-left-alt": "Time Left", |         "time-left-alt": "Time Left", | ||||||
|         "time-to-read-alt": "{{sort-field-pipe.time-to-read}}", |         "time-to-read-alt": "{{sort-field-pipe.time-to-read}}", | ||||||
|         "scrobbling-tooltip": "{{settings.scrobbling}}", |         "scrobbling-tooltip": "{{settings.scrobbling}}: {{value}}", | ||||||
|         "publication-status-title": "Publication", |         "publication-status-title": "Publication", | ||||||
|         "publication-status-tooltip": "Publication Status" |         "publication-status-tooltip": "Publication Status", | ||||||
|  |         "on": "{{reader-settings.on}}", | ||||||
|  |         "off": "{{reader-settings.off}}" | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     "match-series-modal": { |     "match-series-modal": { | ||||||
| @ -1241,7 +1245,8 @@ | |||||||
|         "language-title": "{{edit-chapter-modal.language-label}}", |         "language-title": "{{edit-chapter-modal.language-label}}", | ||||||
|         "release-title": "{{sort-field-pipe.release-year}}", |         "release-title": "{{sort-field-pipe.release-year}}", | ||||||
|         "format-title": "{{metadata-filter.format-label}}", |         "format-title": "{{metadata-filter.format-label}}", | ||||||
|         "length-title": "{{edit-chapter-modal.words-label}}" |         "length-title": "{{edit-chapter-modal.words-label}}", | ||||||
|  |         "age-rating-title": "{{metadata-fields.age-rating-title}}" | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     "related-tab": { |     "related-tab": { | ||||||
| @ -1340,7 +1345,7 @@ | |||||||
|         "reset": "{{common.reset}}", |         "reset": "{{common.reset}}", | ||||||
|         "test": "Test", |         "test": "Test", | ||||||
|         "host-name-label": "Host Name", |         "host-name-label": "Host Name", | ||||||
|         "host-name-tooltip": "Domain Name (of Reverse Proxy). Required for email functionality. If no reverse proxy, use any url.", |         "host-name-tooltip": "The domain name of your reverse proxy, required for email functionality. If 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 /", |         "host-name-validation": "Host name must start with http(s) and not end in /", | ||||||
| 
 | 
 | ||||||
|         "sender-address-label": "Sender Address", |         "sender-address-label": "Sender Address", | ||||||
| @ -1751,7 +1756,19 @@ | |||||||
|         "read-options-alt": "Read options", |         "read-options-alt": "Read options", | ||||||
|         "incognito-alt": "(Incognito)", |         "incognito-alt": "(Incognito)", | ||||||
|         "no-data": "Nothing added", |         "no-data": "Nothing added", | ||||||
|         "characters-title": "{{metadata-fields.characters-title}}" |         "characters-title": "{{metadata-fields.characters-title}}", | ||||||
|  |         "writers-title": "{{metadata-fields.writers-title}}", | ||||||
|  |         "cover-artists-title": "{{metadata-fields.cover-artists-title}}", | ||||||
|  |         "publishers-title": "{{metadata-fields.publishers-title}}", | ||||||
|  |         "items-title": "Items", | ||||||
|  |         "storyline-tab": "{{series-detail.storyline-tab}}", | ||||||
|  |         "details-tab": "{{series-detail.details-tab}}", | ||||||
|  |         "edit-alt": "{{common.edit}}", | ||||||
|  |         "edit-label": "Edit Mode", | ||||||
|  |         "date-range-title": "Date Range", | ||||||
|  |         "more-alt": "{{series-detail.more-alt}}", | ||||||
|  |         "reorder-alt": "Reorder Items", | ||||||
|  |         "dnd-warning": "Drag and drop is unavailable on mobile devices or when the reading list has more than 100 items." | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     "events-widget": { |     "events-widget": { | ||||||
| @ -2617,7 +2634,8 @@ | |||||||
|         "person-image-downloaded": "Person cover was downloaded and applied.", |         "person-image-downloaded": "Person cover was downloaded and applied.", | ||||||
|         "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", |         "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", | ||||||
|         "match-success": "Series matched correctly", |         "match-success": "Series matched correctly", | ||||||
|         "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon." |         "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.", | ||||||
|  |         "scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services." | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     "read-time-pipe": { |     "read-time-pipe": { | ||||||
|  | |||||||
| @ -38,15 +38,55 @@ | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .btn-outline-secondary { | .btn-outline-secondary { | ||||||
|  |   /** | ||||||
|  |   --btn-secondary-text-color: white; | ||||||
|  |     --btn-secondary-bg-color: #6c757d; | ||||||
|  |     --btn-secondary-border-color: #6c757d; | ||||||
|  |     --btn-secondary-hover-bg-color: var(--bs-btn-hover-bg); | ||||||
|  |     --btn-secondary-hover-border-color: var(--bs-btn-hover-border-color); | ||||||
|  |     --btn-secondary-hover-border-color: var(--bs-btn-hover-border-color); | ||||||
|  |     --btn-secondary-font-weight: bold; | ||||||
|  |     --btn-secondary-outline-text-color: white; | ||||||
|  |     --btn-secondary-outline-bg-color: transparent; | ||||||
|  |     --btn-secondary-outline-border-color: #6c757d; | ||||||
|  |     --btn-secondary-outline-hover-bg-color: transparent; | ||||||
|  |     --btn-secondary-outline-hover-border-color: transparent; | ||||||
|  |     --btn-secondary-outline-font-weight: bold; | ||||||
|  | 
 | ||||||
|  |     vs bootstrap | ||||||
|  |       --bs-btn-color: var(--btn-secondary-bg-color); | ||||||
|  |       --bs-btn-border-color: var(--btn-secondary-border-color); | ||||||
|  |       --bs-btn-hover-color: var(--btn-secondary-hover-text-color); | ||||||
|  |       --bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color); | ||||||
|  |       --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); | ||||||
|  |       --bs-btn-focus-shadow-rgb: 108, 117, 125; | ||||||
|  |       --bs-btn-active-color: #fff; | ||||||
|  |       --bs-btn-active-bg: #6c757d; | ||||||
|  |       --bs-btn-active-border-color: #6c757d; | ||||||
|  |       --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); | ||||||
|  |       --bs-btn-disabled-color: #6c757d; | ||||||
|  |       --bs-btn-disabled-bg: transparent; | ||||||
|  |       --bs-btn-disabled-border-color: #6c757d; | ||||||
|  |       --bs-gradient: none; | ||||||
|  |    */ | ||||||
|  | 
 | ||||||
|  |   // Override bootstrap variables | ||||||
|  |   --bs-btn-color: var(--btn-secondary-bg-color); | ||||||
|  |   --bs-btn-border-color: var(--btn-secondary-border-color); | ||||||
|  |   --bs-btn-hover-color: var(--btn-secondary-hover-text-color); | ||||||
|  |   --bs-btn-hover-bg: var(--btn-secondary-outline-hover-bg-color); | ||||||
|  |   --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|   color: var(--btn-secondary-outline-text-color); |   color: var(--btn-secondary-outline-text-color); | ||||||
|   background-color: var(--btn-secondary-outline-bg-color); |   background-color: var(--btn-secondary-outline-bg-color); | ||||||
|   border-color: var(--btn-secondary-outline-border-color); |   border-color: var(--btn-secondary-outline-border-color); | ||||||
|   border-radius: 0; |  | ||||||
| 
 | 
 | ||||||
|   &:hover { |   &:hover { | ||||||
|     --bs-btn-color: var(--btn-secondary-outline-hover-text-color); |     --bs-btn-color: var(--btn-secondary-outline-hover-text-color); | ||||||
|     --bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color); |     --bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color); | ||||||
|     --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); |     --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); | ||||||
|  |     --btn-secondary-outline-text-color: var(--btn-secondary-outline-hover-bg-color); | ||||||
| 
 | 
 | ||||||
|     color: var(--btn-secondary-outline-hover-text-color); |     color: var(--btn-secondary-outline-hover-text-color); | ||||||
|     background-color: var(--btn-secondary-outline-hover-bg-color); |     background-color: var(--btn-secondary-outline-hover-bg-color); | ||||||
| @ -109,17 +149,6 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di | |||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .btn-text { |  | ||||||
|   color: var(--primary-color); |  | ||||||
|   > i { |  | ||||||
|     color: var(--primary-color) !important; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   &:hover, &:focus { |  | ||||||
|     color: var(--primary-color); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| .btn:focus, .btn:active, .btn:active:focus { | .btn:focus, .btn:active, .btn:active:focus { | ||||||
|   box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important; |   box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important; | ||||||
| @ -131,6 +160,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di | |||||||
|   color: var(--body-text-color); |   color: var(--body-text-color); | ||||||
|   border: none; |   border: none; | ||||||
| 
 | 
 | ||||||
|  |   &:disabled { | ||||||
|  |     --bs-btn-disabled-bg: transparent; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   &:hover, &:focus { |   &:hover, &:focus { | ||||||
|     color: var(--body-text-color); |     color: var(--body-text-color); | ||||||
|     border: none; |     border: none; | ||||||
|  | |||||||
| @ -146,9 +146,10 @@ | |||||||
|     --btn-secondary-font-weight: bold; |     --btn-secondary-font-weight: bold; | ||||||
|     --btn-secondary-outline-text-color: white; |     --btn-secondary-outline-text-color: white; | ||||||
|     --btn-secondary-outline-bg-color: transparent; |     --btn-secondary-outline-bg-color: transparent; | ||||||
|     --btn-secondary-outline-border-color: transparent; |     --btn-secondary-outline-border-color: #6c757d; | ||||||
|     --btn-secondary-outline-hover-bg-color: transparent; |     --btn-secondary-outline-hover-text-color: #fff; | ||||||
|     --btn-secondary-outline-hover-border-color: transparent; |     --btn-secondary-outline-hover-bg-color: var(--btn-secondary-bg-color); | ||||||
|  |     --btn-secondary-outline-hover-border-color: var(--btn-secondary-bg-color); | ||||||
|     --btn-secondary-outline-font-weight: bold; |     --btn-secondary-outline-font-weight: bold; | ||||||
|     --btn-primary-text-text-color: white; |     --btn-primary-text-text-color: white; | ||||||
|     --btn-secondary-text-text-color: lightgrey; |     --btn-secondary-text-text-color: lightgrey; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user