diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 253f6b7c2..e333bd42a 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -66,10 +66,12 @@ public class CollectionController : BaseApiController /// /// [HttpGet("single")] - public async Task>> GetTag(int collectionId) + public async Task> GetTag(int collectionId) { - var collections = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(UserId, false); - return Ok(collections.FirstOrDefault(c => c.Id == collectionId)); + var result = await _unitOfWork.CollectionTagRepository.GetCollectionDtoAsync(collectionId, UserId); + if (result == null) return NotFound(); // TODO: Figure out how to best handle restrictions/not found across the codebase + + return Ok(result); } /// @@ -101,10 +103,10 @@ public class CollectionController : BaseApiController /// UI does not contain controls to update title /// /// - /// + /// The updated tag entity [HttpPost("update")] [DisallowRole(PolicyConstants.ReadOnlyRole)] - public async Task UpdateTag(AppUserCollectionDto updatedTag) + public async Task> UpdateTag(AppUserCollectionDto updatedTag) { try { @@ -112,7 +114,7 @@ public class CollectionController : BaseApiController { await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated, MessageFactory.CollectionUpdatedEvent(updatedTag.Id), false); - return Ok(await _localizationService.Translate(UserId, "collection-updated-successfully")); + return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtoAsync(updatedTag.Id, UserId)); } } catch (KavitaException ex) diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 67b373c08..c9e864de1 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -24,6 +24,7 @@ using EasyCaching.Core; using Hangfire; using Kavita.Common; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -68,10 +69,10 @@ public class LibraryController : BaseApiController /// Creates a new Library. Upon library creation, adds new library to all Admin accounts. /// /// - /// + /// Created Library [Authorize(Policy = PolicyGroups.AdminPolicy)] [HttpPost("create")] - public async Task AddLibrary(UpdateLibraryDto dto) + public async Task> AddLibrary(UpdateLibraryDto dto) { if (await _unitOfWork.LibraryRepository.LibraryExists(dto.Name)) { @@ -161,7 +162,7 @@ public class LibraryController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(UserId), false); - return Ok(); + return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtoByIdAsync(library.Id)); } /// @@ -207,17 +208,18 @@ public class LibraryController : BaseApiController /// /// Return a specific library /// + /// If the user is not an admin, only id, type, and name will be returned /// - [Authorize(Policy = PolicyGroups.AdminPolicy)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] [HttpGet] public async Task> GetLibrary(int libraryId) { - var username = Username!; - if (string.IsNullOrEmpty(username)) return Unauthorized(); - - var libraries = await GetLibrariesForUser(username); - - return Ok(libraries.FirstOrDefault(l => l.Id == libraryId)); + if (User.IsInRole(PolicyConstants.AdminRole)) + { + return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtoByIdAsync(libraryId)); + } + return Ok(await _unitOfWork.LibraryRepository.GetLiteLibraryDtoByIdAsync(libraryId)); } /// @@ -227,10 +229,7 @@ public class LibraryController : BaseApiController [HttpGet("libraries")] public async Task>> GetLibraries() { - var username = Username!; - if (string.IsNullOrEmpty(username)) return Unauthorized(); - - return Ok(await GetLibrariesForUser(username)); + return Ok(await GetLibrariesForUser(Username!)); } /// @@ -674,7 +673,7 @@ public class LibraryController : BaseApiController await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); - return Ok(); + return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtoByIdAsync(library.Id)); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 52fc83771..e9e1e5fcd 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -159,9 +159,9 @@ public class SeriesController : BaseApiController /// Updates the Series /// /// - /// + /// Updated Series [HttpPost("update")] - public async Task UpdateSeries(UpdateSeriesDto updateSeries) + public async Task> UpdateSeries(UpdateSeriesDto updateSeries) { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); if (series == null) @@ -206,7 +206,7 @@ public class SeriesController : BaseApiController await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); } - return Ok(); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(series.Id, UserId)); } /// @@ -233,7 +233,7 @@ public class SeriesController : BaseApiController /// Page size and offset /// [HttpPost("recently-updated-series")] - public async Task>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams) + public async Task>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(UserId, userParams)); diff --git a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs deleted file mode 100644 index bb0360b30..000000000 --- a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using API.Entities.Enums; - -namespace API.DTOs.Dashboard; - -/// -/// A mesh of data for Recently added volume/chapters -/// -public sealed record RecentlyAddedItemDto -{ - public string SeriesName { get; set; } = default!; - public int SeriesId { get; set; } - public int LibraryId { get; set; } - public LibraryType LibraryType { get; set; } - /// - /// This will automatically map to Volume X, Chapter Y, etc. - /// - public string Title { get; set; } = default!; - public DateTime Created { get; set; } - /// - /// Chapter Id if this is a chapter. Not guaranteed to be set. - /// - public int ChapterId { get; set; } = 0; - /// - /// Volume Id if this is a chapter. Not guaranteed to be set. - /// - public int VolumeId { get; set; } = 0; - /// - /// This is used only on the UI. It is just index of being added. - /// - public int Id { get; set; } - public MangaFormat Format { get; set; } - -} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index c038c5d69..a493b3626 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -6,15 +6,22 @@ using API.Entities.Enums; namespace API.DTOs; #nullable enable -public sealed record LibraryDto +/// +/// This is a LibraryDto that non-admins can resolve that has the core information they need +/// +public record LiteLibraryDto { public int Id { get; init; } public string? Name { get; init; } + public LibraryType Type { get; init; } +} + +public sealed record LibraryDto : LiteLibraryDto +{ /// /// Last time Library was scanned /// public DateTime LastScanned { get; init; } - public LibraryType Type { get; init; } /// /// An optional Cover Image or null /// diff --git a/API/Data/AutoMapper/AutoMapperProfiles.cs b/API/Data/AutoMapper/AutoMapperProfiles.cs index ac4b938dc..97966e257 100644 --- a/API/Data/AutoMapper/AutoMapperProfiles.cs +++ b/API/Data/AutoMapper/AutoMapperProfiles.cs @@ -231,7 +231,7 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)); - + CreateMap(); CreateMap() .ForMember(dest => dest.Folders, opt => diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 41b29686f..e1741c6c7 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -50,6 +50,11 @@ public interface ICollectionTagRepository /// /// Task> GetCollectionDtosAsync(int userId, bool includePromoted = false); + /// + /// Returns the collection if the user owns it or the collection is promoted + /// + /// + Task GetCollectionDtoAsync(int collectionId, int userId); Task> GetCollectionDtosPagedAsync(int userId, UserParams userParams, bool includePromoted = false); Task> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false); @@ -108,6 +113,17 @@ public class CollectionTagRepository : ICollectionTagRepository .ToListAsync(); } + public async Task GetCollectionDtoAsync(int collectionId, int userId) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.AppUserCollection + .Where(uc => (uc.AppUserId == userId || uc.Promoted) && uc.Id == collectionId) + .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating) + .OrderBy(uc => uc.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + public async Task> GetCollectionDtosAsync(int userId, bool includePromoted = false) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index c410ff483..9c32b169c 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -37,6 +37,8 @@ public interface ILibraryRepository void Update(Library library); void Delete(Library? library); Task> GetLibraryDtosAsync(); + Task GetLibraryDtoByIdAsync(int libraryId); + Task GetLiteLibraryDtoByIdAsync(int libraryId); Task LibraryExists(string libraryName); Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None); Task> GetLibraryDtosForUsernameAsync(string userName); @@ -214,6 +216,23 @@ public class LibraryRepository : ILibraryRepository .ToListAsync(); } + public async Task GetLibraryDtoByIdAsync(int libraryId) + { + return await _context.Library + .Include(f => f.Folders) + .Include(l => l.LibraryFileTypes) + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .FirstOrDefaultAsync(l => l.Id == libraryId); + } + + public async Task GetLiteLibraryDtoByIdAsync(int libraryId) + { + return await _context.Library + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(l => l.Id == libraryId); + } + public async Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None) { diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs index c7af70f65..8f0e9a364 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -32,7 +32,7 @@ public static class SeriesSort SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions), SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, sortOptions), SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId) - .Select(p => p.LastModified) // TODO: Migrate this to UTC + .Select(p => p.LastModifiedUtc) .Max(), sortOptions), SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings .Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions), diff --git a/API/I18N/ca.json b/API/I18N/ca.json index b314a9374..139b6c7b5 100644 --- a/API/I18N/ca.json +++ b/API/I18N/ca.json @@ -61,5 +61,9 @@ "reading-list-item-delete": "No s'han pogut suprimir els elements", "generic-reading-list-create": "S'ha produït un problema en crear la llista de lectura", "generic-reading-list-update": "S'ha produït un problema en actualitzar la llista de lectura", - "generic-create-temp-archive": "S'ha produït un problema en crear l'arxiu temporal" + "generic-create-temp-archive": "S'ha produït un problema en crear l'arxiu temporal", + "locked-out": "S'ha bloquejat l'accés degut a masses intents d'autentificació. Si us plau, espera 10 minuts.", + "disabled-account": "El seu compte s'ha deshabilitat. Contacti amb l'administrador.", + "register-user": "Hi ha hagut un error al registrar-se", + "validate-email": "Hi ha hagut un error al validar l'e-mail: {0}" } diff --git a/API/I18N/ga.json b/API/I18N/ga.json index 441c05014..6d5d286e8 100644 --- a/API/I18N/ga.json +++ b/API/I18N/ga.json @@ -87,7 +87,7 @@ "bookmark-dir-permissions": "Níl na ceadanna cearta ag an Eolaire Leabharmharcanna chun Kavita a úsáid", "total-backups": "Caithfidh Cúltaca Iomlána a bheith idir 1 agus 30", "total-logs": "Caithfidh an Logchomhaid Iomlán a bheith idir 1 agus 30", - "url-not-valid": "Ní sheolann URL íomhá bhailí ar ais nó éilíonn sé údarú", + "url-not-valid": "Ní thugann an URL íomhá bhailí ar ais nó teastaíonn údarú uaidh", "url-required": "Caithfidh tú pas a fháil i url le húsáid", "generic-cover-series-save": "Ní féidir íomhá an chlúdaigh a shábháil sa tSraith", "generic-cover-collection-save": "Ní féidir íomhá an chlúdaigh a shábháil sa Bhailiúchán", diff --git a/API/I18N/ru.json b/API/I18N/ru.json index 0b6182ece..c81241893 100644 --- a/API/I18N/ru.json +++ b/API/I18N/ru.json @@ -1,5 +1,5 @@ { - "confirm-email": "Сначала Вы должны подтвердить свой адрес электронной почты", + "confirm-email": "Вы должны подтвердить адрес своей электронной почты", "generate-token": "Возникла проблема с генерацией токена подтверждения электронной почты. Смотрите журналы", "invalid-password": "Неверный пароль", "invalid-email-confirmation": "Неверное подтверждение электронной почты", @@ -9,7 +9,7 @@ "email-sent": "Электронное письмо отправлено", "generic-password-update": "В процессе подтверждения нового пароля возникла непредвиденная ошибка", "user-already-confirmed": "Пользователь уже подтвержден", - "user-migration-needed": "Для этой учётной записи требуется миграция. Попросите пользователя повторно авторизоваться для запуска процесса переноса", + "user-migration-needed": "Для этой учётной записи требуется перенос. Попросите пользователя повторно авторизоваться для запуска процесса переноса", "generic-user-update": "При обновлении пользователя возникало исключение", "disabled-account": "Ваша учетная запись отключена. Обратитесь к администратору сервера.", "locked-out": "Доступ временно заблокирован из-за превышения числа попыток входа. Пожалуйста, подождите 10 минут.", @@ -57,7 +57,7 @@ "generic-reading-list-create": "Возникла проблема с созданием читательского списка", "no-cover-image": "Нет изображения обложки", "collection-updated": "Коллекция успешно обновлена", - "critical-email-migration": "Возникла проблема в процессе смены электронной почты. Обратитесь в службу поддержки", + "critical-email-migration": "Возникла проблема в процессе переноса электронной почты. Обратитесь в службу поддержки", "cache-file-find": "Не удалось найти кешированное изображение. Перезагрузите страницу и попробуйте снова.", "duplicate-bookmark": "Закладка с такими параметрами уже существует", "collection-tag-duplicate": "Коллекция с таким именем уже существует", diff --git a/API/I18N/sk.json b/API/I18N/sk.json index 01f2ce9e4..7b48fe208 100644 --- a/API/I18N/sk.json +++ b/API/I18N/sk.json @@ -220,5 +220,8 @@ "genre-doesnt-exist": "Žáner neexistuje", "font-url-not-allowed": "Nahrávanie písma pomocou adresy URL je povolené iba z Google Fonts", "annotation-export-failed": "Nepodarilo sa exportovať anotácie, skontrolujte protokoly", - "download-not-allowed": "Používateľ nemá povolenia na sťahovanie" + "download-not-allowed": "Používateľ nemá povolenia na sťahovanie", + "client-device-doesnt-exist": "Klientske zariadenie neexistuje", + "auth-key-unique": "Názov autorizačného kľúča musí byť jedinečný pre váš účet", + "role-restricted": "Prístup odmietnutý: Vaša rola nemá povolenie na túto akciu" } diff --git a/API/I18N/sv.json b/API/I18N/sv.json index dd1e597f4..51f37da52 100644 --- a/API/I18N/sv.json +++ b/API/I18N/sv.json @@ -208,5 +208,7 @@ "smart-filter-name-required": "Smart Filter namn krävs", "sidenav-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från sidomenyn", "smart-filter-system-name": "Du kan inte använda namnet från en ström som systemet tillhandahåller", - "password-authentication-disabled": "Lösenords autentisering har stängts av, logga in via OpenID Connect" + "password-authentication-disabled": "Lösenords autentisering har stängts av, logga in via OpenID Connect", + "oidc-managed": "Användare som hanteras av OIDC kan inte redigeras.", + "cannot-change-identity-provider-original-user": "Identitetsleverantören för det ursprungliga administratörskontot kan inte ändras" } diff --git a/API/Program.cs b/API/Program.cs index 8dd65883b..15dad328e 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -172,10 +172,10 @@ public class Program private static void HandleFirstRunConfiguration() { var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json"); - if (File.Exists(firstRunConfigFilePath) && - !File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"))) + var actualRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"); + if (File.Exists(firstRunConfigFilePath) && !File.Exists(actualRunConfigFilePath)) { - File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")); + File.Move(firstRunConfigFilePath, actualRunConfigFilePath); } } diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 90f568d40..31c9a2d0c 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -292,7 +292,7 @@ public class LocalizationService : ILocalizationService try { var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-')); - return cultureInfo.NativeName; + return cultureInfo.EnglishName; } catch { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 292217862..7bb7bd79d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,4 +68,8 @@ If you just want to play with Swagger, you can just If you have a build issue around swagger run: ` swagger tofile --output ../openapi.json API/bin/Debug/net8.0/API.dll v1` to see the error and correct it +### Building external scripts/apps ### +We welcome anyone to build external scripts and applications. Reach out to us about publishing, we will link from our wiki and discord. +Please do not use words like "Kavita reader" or "Kavita" as your explicit app name. Use of "[name]: A Kavita Reader" is preferred. + If you have any questions about any of this, please let us know. diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index c810d89ad..ac5f7e8c9 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -20,4 +20,4 @@ - \ No newline at end of file + diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index a793fa745..aead7b631 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -1,5 +1,5 @@ -$image-height: 232.91px; -$image-width: 160px; +$image-height: 14.5569rem; +$image-width: 10rem; .card-item-container { width: $image-width; @@ -7,25 +7,25 @@ $image-width: 160px; .error-banner { width: $image-width; - height: 18px; + height: 1.125rem; background-color: var(--toast-error-bg-color); - font-size: 12px; + font-size: 0.75rem; color: white; text-transform: uppercase; text-align: center; position: absolute; - top: 0px; - right: 0px; + top: 0; + right: 0; } .selected-highlight { - outline: 2px solid var(--primary-color); + outline: 0.125rem solid var(--primary-color); } .progress-banner { width: $image-width; - height: 5px; + height: 0.3125rem; .progress { color: var(--card-progress-bar-color); @@ -34,15 +34,15 @@ $image-width: 160px; } .download { - width: 80px; - height: 80px; + width: 5rem; + height: 5rem; position: absolute; top: 25%; right: 30%; } .badge-container { - border-radius: 4px; + border-radius: 0.25rem; display: block; height: $image-height; left: 0; @@ -50,13 +50,13 @@ $image-width: 160px; pointer-events: none; position: absolute; top: 0; - width: 160px; + width: 10rem; } .not-read-badge { position: absolute; top: calc(-1 * (var(--card-progress-triangle-size) / 2)); - right: -14px; + right: -0.875rem; z-index: 1000; height: var(--card-progress-triangle-size); width: var(--card-progress-triangle-size); @@ -66,8 +66,8 @@ $image-width: 160px; .bulk-mode { position: absolute; - top: 5px; - left: 5px; + top: 0.3125rem; + left: 0.3125rem; visibility: hidden; &.always-show { @@ -77,8 +77,8 @@ $image-width: 160px; } input[type="checkbox"] { - width: 20px; - height: 20px; + width: 1.25rem; + height: 1.25rem; color: var(--checkbox-bg-color); } } @@ -117,10 +117,10 @@ $image-width: 160px; top: 0; left: 0; width: 100%; - height: 232.91px; + height: 14.5569rem; transition: all 0.2s; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; &:hover { background-color: var(--card-overlay-hover-bg-color); @@ -130,7 +130,7 @@ $image-width: 160px; .overlay-information--centered { position: absolute; background-color: rgba(0, 0, 0, 0.7); - border-radius: 50px; + border-radius: 3.125rem; top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -145,8 +145,8 @@ $image-width: 160px; } .count { - top: 5px; - right: 10px; + top: 0.3125rem; + right: 0.625rem; position: absolute; } } @@ -156,26 +156,26 @@ $image-width: 160px; } .library { - font-size: 13px; + font-size: 0.8125rem; text-decoration: none; - margin-top: 0px; + margin-top: 0; } .card-title-container { display: flex; justify-content: space-between; align-items: center; - padding: 0 5px; + padding: 0 0.3125rem; :first-child { - min-width: 22px; + min-width: 1.375rem; } .card-title { font-size: 0.8rem; margin: 0; text-align: center; - max-width: 90px; + max-width: 5.625rem; a { overflow: hidden; @@ -185,15 +185,15 @@ $image-width: 160px; } .card-actions { - min-width: 15.82px; + min-width: 0.9888rem; } .card-format { - min-width: 22px; + min-width: 1.375rem; } ::ng-deep app-card-actionables .dropdown .dropdown-toggle { - padding: 0 5px; + padding: 0 0.3125rem; } .meta-title { @@ -205,9 +205,9 @@ $image-width: 160px; .card-title { font-size: 0.8rem; margin: 0; - padding: 10px 0; + padding: 0.625rem 0; text-align: center; - max-width: 120px; + max-width: 7.5rem; a { overflow: hidden; @@ -216,7 +216,7 @@ $image-width: 160px; } .card-body > div:nth-child(2) { - height: 40px; + height: 2.5rem; overflow: hidden; -webkit-line-clamp: 2; display: -webkit-box; @@ -229,7 +229,7 @@ $image-width: 160px; visibility: hidden; display: none; .card-title { - padding: 10px; + padding: 0.625rem; } } @@ -239,11 +239,11 @@ $image-width: 160px; .expected { .overlay-information--centered { div { - height: 32px; - width: 32px; + height: 2rem; + width: 2rem; i { font-size: 1.4rem; - line-height: 32px; + line-height: 2rem; } } } diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss index 9b54a5fad..c2cbe4cf4 100644 --- a/UI/Web/src/_manga-reader-common.scss +++ b/UI/Web/src/_manga-reader-common.scss @@ -1,4 +1,4 @@ -$scrollbarHeight: 35px; +$scrollbarHeight: 2.1875rem; img { user-select: none; @@ -67,12 +67,12 @@ img { .highlight { background-color: var(--manga-reader-next-highlight-bg-color) !important; animation: fadein .5s both; - backdrop-filter: blur(10px); + backdrop-filter: blur(0.625rem); } .highlight-2 { background-color: var(--manga-reader-prev-highlight-bg-color) !important; animation: fadein .5s both; - backdrop-filter: blur(10px); + backdrop-filter: blur(0.625rem); } @@ -83,19 +83,19 @@ img { left: 50%; height: 100%; box-shadow: - 0px 0px calc(17px*3.14) 25px rgb(0 0 0 / 43%), - 0px 0px calc(2px*3.14) 2px rgb(0 0 0 / 43%), - 0px 0px calc(5px*3.14) 4px rgb(0 0 0 / 43%), - 0px 0px calc(0.5px*3.14) 0.3px rgb(0 0 0 / 43%); + 0 0 calc(1.0625rem*3.14) 1.5625rem rgb(0 0 0 / 43%), + 0 0 calc(0.125rem*3.14) 0.125rem rgb(0 0 0 / 43%), + 0 0 calc(0.3125rem*3.14) 0.25rem rgb(0 0 0 / 43%), + 0 0 calc(0.0312rem*3.14) 0.0187rem rgb(0 0 0 / 43%); } @supports (-moz-appearance:none) { ::ng-deep .image-container.book-shadow[class*="double-offset"]:before, ::ng-deep .image-container.book-shadow.wide:before { box-shadow: - 0px 0px calc(17px*3.14) 25px rgb(0 0 0 / 43%), - 0px 0px calc(2px*3.14) 2px rgb(0 0 0 / 43%), - 0px 0px calc(5px*3.14) 4px rgb(0 0 0 / 43%), - 0px 0px calc(0.5px*3.14) 0.3px rgb(0 0 0 / 43%), - 0px 0px 1px 0.5px rgb(0 0 0 / 43%); + 0 0 calc(1.0625rem*3.14) 1.5625rem rgb(0 0 0 / 43%), + 0 0 calc(0.125rem*3.14) 0.125rem rgb(0 0 0 / 43%), + 0 0 calc(0.3125rem*3.14) 0.25rem rgb(0 0 0 / 43%), + 0 0 calc(0.0312rem*3.14) 0.0187rem rgb(0 0 0 / 43%), + 0 0 0.0625rem 0.0312rem rgb(0 0 0 / 43%); } } diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss index a949d8399..6a53a3699 100644 --- a/UI/Web/src/_series-detail-common.scss +++ b/UI/Web/src/_series-detail-common.scss @@ -8,8 +8,8 @@ .image-container { align-self: flex-start; - max-height: 400px; - max-width: 280px; + max-height: 25rem; + max-width: 17.5rem; } .subtitle { @@ -20,7 +20,7 @@ .main-container { overflow: unset !important; - margin-top: 15px; + margin-top: 0.9375rem; } ::ng-deep .badge-expander .content a { @@ -38,7 +38,7 @@ } .card-body > div:nth-child(2) { - height: 50px; + height: 3.125rem; overflow: hidden; -webkit-line-clamp: 2; display: -webkit-box; @@ -47,17 +47,17 @@ } .under-image ~ .overlay-information { - top: -404px; - height: 364px; + top: -25.25rem; + height: 22.75rem; } .overlay-information { position: relative; - top: -364px; - height: 364px; + top: -22.75rem; + height: 22.75rem; transition: all 0.2s; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; &:hover { cursor: pointer; @@ -70,9 +70,9 @@ .overlay-information--centered { position: absolute; - border-radius: 15px; + border-radius: 0.9375rem; background-color: rgba(0, 0, 0, .7); - border-radius: 50px; + border-radius: 3.125rem; top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -85,11 +85,11 @@ } div { - width: 60px; - height: 60px; + width: 3.75rem; + height: 3.75rem; i { font-size: 1.6rem; - line-height: 60px; + line-height: 3.75rem; width: 100%; } } @@ -118,7 +118,7 @@ -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; scrollbar-width: none; - box-shadow: inset -1px -2px 0px -1px var(--elevation-layer9); + box-shadow: inset -0.0625rem -0.125rem 0 -0.0625rem var(--elevation-layer9); } .carousel-tabs-container::-webkit-scrollbar { display: none; @@ -134,9 +134,9 @@ ::ng-deep .carousel-container .header i.fa-plus, ::ng-deep .carousel-container .header i.fa-pen{ border-width: 1px; border-style: solid; - border-radius: 5px; + border-radius: 0.3125rem; border-color: var(--primary-color); - padding: 5px; + padding: 0.3125rem; vertical-align: middle; &:hover { @@ -145,7 +145,7 @@ } ::ng-deep .image-container.mobile-bg app-image img { - max-height: 400px; + max-height: 25rem; object-fit: contain; } @@ -165,7 +165,7 @@ @media (max-width: theme.$grid-breakpoints-lg) { .image-container.mobile-bg{ width: 100vw; - top: calc(var(--nav-offset) - 20px); + top: calc(var(--nav-offset) - 1.25rem); left: 0; pointer-events: none; position: fixed !important; @@ -178,7 +178,7 @@ ::ng-deep .image-container.mobile-bg app-image img { max-height: unset !important; opacity: 0.05 !important; - filter: blur(5px) !important; + filter: blur(0.3125rem) !important; max-width: 100dvw; height: 100dvh !important; overflow: hidden; diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss index dab6ababd..a613ef469 100644 --- a/UI/Web/src/_tag-card-common.scss +++ b/UI/Web/src/_tag-card-common.scss @@ -1,8 +1,8 @@ .tag-card { background-color: var(--bs-card-color, #2c2c2c); padding: 1rem; - border-radius: 12px; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); + border-radius: 0.75rem; + box-shadow: 0 0.125rem 0.3125rem rgba(0,0,0,0.2); transition: transform 0.2s ease, background 0.3s ease; cursor: pointer; diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts index ab1d0bcde..ee5966699 100644 --- a/UI/Web/src/app/_directives/dbl-click.directive.ts +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -1,4 +1,4 @@ -import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; +import {Directive, HostListener, output} from '@angular/core'; @Directive({ selector: '[appDblClick]', @@ -6,8 +6,8 @@ import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; }) export class DblClickDirective { - @Output() singleClick = new EventEmitter(); - @Output() doubleClick = new EventEmitter(); + readonly singleClick = output(); + readonly doubleClick = output(); private lastTapTime = 0; private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) diff --git a/UI/Web/src/app/_directives/long-click.directive.ts b/UI/Web/src/app/_directives/long-click.directive.ts index 594685667..4c70cf10a 100644 --- a/UI/Web/src/app/_directives/long-click.directive.ts +++ b/UI/Web/src/app/_directives/long-click.directive.ts @@ -1,4 +1,4 @@ -import {Directive, ElementRef, EventEmitter, inject, input, OnDestroy, Output} from '@angular/core'; +import {Directive, ElementRef, inject, input, OnDestroy, output} from '@angular/core'; import {fromEvent, merge, Subscription, switchMap, tap, timer} from "rxjs"; import {takeUntil} from "rxjs/operators"; @@ -18,7 +18,7 @@ export class LongClickDirective implements OnDestroy { */ threshold = input(500); - @Output() longClick = new EventEmitter(); + readonly longClick = output(); constructor() { const start$ = merge( @@ -34,7 +34,7 @@ export class LongClickDirective implements OnDestroy { this.eventSubscribe = start$ .pipe( switchMap(() => timer(this.threshold()).pipe(takeUntil(end$))), - tap(() => this.longClick.emit()) + tap(() => this.longClick.emit(undefined)) ).subscribe(); } diff --git a/UI/Web/src/app/_guards/admin.guard.ts b/UI/Web/src/app/_guards/admin.guard.ts index ade795609..6e1a9a660 100644 --- a/UI/Web/src/app/_guards/admin.guard.ts +++ b/UI/Web/src/app/_guards/admin.guard.ts @@ -1,30 +1,17 @@ -import { Injectable } from '@angular/core'; -import {CanActivate, Router} from '@angular/router'; -import { ToastrService } from 'ngx-toastr'; -import { Observable } from 'rxjs'; -import { map, take } from 'rxjs/operators'; -import { AccountService } from '../_services/account.service'; +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; +import {ToastrService} from 'ngx-toastr'; +import {AccountService} from '../_services/account.service'; import {TranslocoService} from "@jsverse/transloco"; -@Injectable({ - providedIn: 'root' -}) -export class AdminGuard implements CanActivate { - constructor(private accountService: AccountService, private toastr: ToastrService, - private router: Router, - private translocoService: TranslocoService) {} +export const adminGuard: CanActivateFn = () => { + const accountService = inject(AccountService); + const toastr = inject(ToastrService); + const router = inject(Router); + const translocoService = inject(TranslocoService); - canActivate(): Observable { - return this.accountService.currentUser$.pipe(take(1), - map((user) => { - if (user && this.accountService.hasAdminRole(user)) { - return true; - } + if (accountService.hasAdminRole()) return true; - this.toastr.error(this.translocoService.translate('toasts.unauthorized-1')); - this.router.navigateByUrl('/home'); - return false; - }) - ); - } -} + toastr.error(translocoService.translate('toasts.unauthorized-1')); + return router.createUrlTree(['/home']); +}; diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index bc037a081..a1436e18f 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -1,40 +1,25 @@ -import {inject, Injectable} from '@angular/core'; -import { CanActivate, Router } from '@angular/router'; -import { ToastrService } from 'ngx-toastr'; -import { Observable } from 'rxjs'; -import { map, take } from 'rxjs/operators'; -import { AccountService } from '../_services/account.service'; +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; +import {ToastrService} from 'ngx-toastr'; +import {AccountService} from '../_services/account.service'; import {TranslocoService} from "@jsverse/transloco"; import {APP_BASE_HREF} from "@angular/common"; -@Injectable({ - providedIn: 'root' -}) -export class AuthGuard implements CanActivate { - private accountService = inject(AccountService); - private router = inject(Router); - private toastr = inject(ToastrService); - private translocoService = inject(TranslocoService); +export const AUTH_URL_KEY = 'kavita--auth-intersection-url'; +export const authGuard: CanActivateFn = () => { + const accountService = inject(AccountService); + const router = inject(Router); + const toastr = inject(ToastrService); + const translocoService = inject(TranslocoService); + const baseURL = inject(APP_BASE_HREF); - public static urlKey: string = 'kavita--auth-intersection-url'; + if (accountService.isLoggedIn()) return true; - baseURL = inject(APP_BASE_HREF); - - canActivate(): Observable { - return this.accountService.currentUser$.pipe(take(1), - map((user) => { - if (user) { - return true; - } - - const path = window.location.pathname; - if (path !== '/login' && !path.startsWith(this.baseURL + "registration") && path !== '') { - localStorage.setItem(AuthGuard.urlKey, path); - } - this.router.navigateByUrl('/login'); - return false; - }) - ); + const path = window.location.pathname; + if (path !== '/login' && !path.startsWith(baseURL + 'registration') && path !== '') { + localStorage.setItem(AUTH_URL_KEY, path); } -} + toastr.error(translocoService.translate('toasts.unauthorized-1')); + return router.createUrlTree(['/login']); +}; diff --git a/UI/Web/src/app/_guards/library-access.guard.ts b/UI/Web/src/app/_guards/library-access.guard.ts index d1054de33..f0f9e55b4 100644 --- a/UI/Web/src/app/_guards/library-access.guard.ts +++ b/UI/Web/src/app/_guards/library-access.guard.ts @@ -1,18 +1,23 @@ -import { Injectable, inject } from '@angular/core'; -import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { Observable, of } from 'rxjs'; -import { MemberService } from '../_services/member.service'; +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; +import {of} from 'rxjs'; +import {MemberService} from '../_services/member.service'; +import {map} from "rxjs/operators"; -@Injectable({ - providedIn: 'root' -}) -export class LibraryAccessGuard implements CanActivate { - private memberService = inject(MemberService); +export const libraryAccessGuard: CanActivateFn = (route, state) => { + const memberService = inject(MemberService); + const router = inject(Router); + const libraryId = parseInt( + route.parent?.paramMap.get('libraryId') ?? route.paramMap.get('libraryId') ?? '', + 10 + ); - canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - const libraryId = parseInt(state.url.split('library/')[1], 10); - if (isNaN(libraryId)) return of(false); - return this.memberService.hasLibraryAccess(libraryId); + if (isNaN(libraryId)) { + return of(router.parseUrl('/home')); } -} + + return memberService.hasLibraryAccess(libraryId).pipe( + map(hasAccess => hasAccess || router.parseUrl('/home')) + ); +}; diff --git a/UI/Web/src/app/_guards/profile.guard.ts b/UI/Web/src/app/_guards/profile.guard.ts index 1342f602d..d155a6363 100644 --- a/UI/Web/src/app/_guards/profile.guard.ts +++ b/UI/Web/src/app/_guards/profile.guard.ts @@ -16,7 +16,7 @@ export const profileGuard: CanActivateFn = (route, state) => { const toastr = inject(ToastrService); // If this is my profile, allow - if (accountService.currentUserSignal()?.id === userId) { + if (accountService.currentUser()?.id === userId) { return true; } diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index f6c92f90e..66d7bbd37 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -6,7 +6,7 @@ import {ToastrService} from 'ngx-toastr'; import {catchError} from 'rxjs/operators'; import {AccountService} from '../_services/account.service'; import {translate, TranslocoService} from "@jsverse/transloco"; -import {AuthGuard} from "../_guards/auth.guard"; +import {AUTH_URL_KEY} from "../_guards/auth.guard"; import {APP_BASE_HREF} from "@angular/common"; export const errorInterceptor: HttpInterceptorFn = (req, next) => { @@ -136,7 +136,7 @@ function handleAuthError( const path = window.location.pathname; if (path !== '/login' && !path.startsWith(baseURL + "registration") && path !== '') { - localStorage.setItem(AuthGuard.urlKey, path); + localStorage.setItem(AUTH_URL_KEY, path); } if (error.error && error.error !== 'Unauthorized') { diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index b5a1153dc..e64824999 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -4,7 +4,7 @@ import {AccountService} from '../_services/account.service'; export const jwtInterceptor: HttpInterceptorFn = (req, next) => { const accountService = inject(AccountService); - const user = accountService.currentUserSignal(); + const user = accountService.currentUser(); if (user?.token) { req = req.clone({ diff --git a/UI/Web/src/app/_models/actionables/action-item.ts b/UI/Web/src/app/_models/actionables/action-item.ts new file mode 100644 index 000000000..77095777f --- /dev/null +++ b/UI/Web/src/app/_models/actionables/action-item.ts @@ -0,0 +1,40 @@ +import {Observable} from "rxjs"; +import {Action} from "./action"; +import {User} from "../user/user"; +import {Role} from "../../_services/account.service"; +import {ActionResultCallback} from "./action-result"; + +/** + * Callback for an action + */ +export type ActionCallback = (action: ActionItem, entity: T) => void; +export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; + +export interface ActionItem { + title: string; + description: string; + action: Action; + callback: ActionResultCallback; + /** + * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. + */ + requiredRoles: Role[]; + children: Array>; + /** + * An optional class which applies to an item. ie) danger on a delete action + */ + class?: string; + /** + * Indicates that there exists a separate list will be loaded from an API. + * Rule: If using this, only one child should exist in children with the Action for dynamicList. + */ + dynamicList?: Observable<{title: string, data: any}[]> | undefined; + /** + * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dynamicList return + */ + _extra?: {title: string, data: any}; + /** + * Will call on each action to determine if it should show for the appropriate entity based on state and user + */ + shouldRender: ActionShouldRenderFunc; +} diff --git a/UI/Web/src/app/_models/actionables/action-result.ts b/UI/Web/src/app/_models/actionables/action-result.ts new file mode 100644 index 000000000..f33b94dac --- /dev/null +++ b/UI/Web/src/app/_models/actionables/action-result.ts @@ -0,0 +1,12 @@ +import {Action} from "./action"; +import {ActionItem} from "./action-item"; +import {Observable} from "rxjs"; + +export type ActionResultCallback = (action: ActionItem, entity: T) => Observable>; + +export type ActionEffect = 'update' | 'remove' | 'reload' | 'none'; +export interface ActionResult { + action: Action; + entity: T; + effect: ActionEffect; +} diff --git a/UI/Web/src/app/_models/actionables/action.ts b/UI/Web/src/app/_models/actionables/action.ts new file mode 100644 index 000000000..fc04110d7 --- /dev/null +++ b/UI/Web/src/app/_models/actionables/action.ts @@ -0,0 +1,115 @@ +export enum Action { + Submenu = -1, + /** + * Mark entity as read + */ + MarkAsRead = 0, + /** + * Mark entity as unread + */ + MarkAsUnread = 1, + /** + * Invoke a Scan on Series/Library + */ + Scan = 2, + /** + * Delete the entity + */ + Delete = 3, + /** + * Open edit modal + */ + Edit = 4, + /** + * Open details modal + */ + Info = 5, + /** + * Invoke a refresh covers + */ + RefreshMetadata = 6, + /** + * Download the entity + */ + Download = 7, + /** + * Invoke an Analyze Files which calculates word count + */ + AnalyzeFiles = 8, + /** + * Read in incognito mode aka no progress tracking + */ + IncognitoRead = 9, + /** + * Add to reading list + */ + AddToReadingList = 10, + /** + * Add to collection + */ + AddToCollection = 11, + /** + * Open Series detail page for said series + */ + ViewSeries = 13, + /** + * Open the reader for entity + */ + Read = 14, + /** + * Add to user's Want to Read List + */ + AddToWantToReadList = 15, + /** + * Remove from user's Want to Read List + */ + RemoveFromWantToReadList = 16, + /** + * Send to a device + */ + SendTo = 17, + /** + * Import some data into Kavita + */ + Import = 18, + /** + * Removes the Series from On Deck inclusion + */ + RemoveFromOnDeck = 19, + AddRuleGroup = 20, + RemoveRuleGroup = 21, + MarkAsVisible = 22, + MarkAsInvisible = 23, + /** + * Promotes the underlying item (Reading List, Collection) + */ + Promote = 24, + UnPromote = 25, + /** + * Invoke refresh covers as false to generate colorscapes + */ + GenerateColorScape = 26, + /** + * Copy settings from one entity to another + */ + CopySettings = 27, + /** + * Match an entity with an upstream system + */ + Match = 28, + /** + * Merge two (or more?) entities + */ + Merge = 29, + /** + * Add to a reading profile + */ + SetReadingProfile = 30, + /** + * Remove the reading profile from the entity + */ + ClearReadingProfile = 31, + Export = 32, + Like = 33, + UnLike = 34, +} diff --git a/UI/Web/src/app/_models/card/card-configuration.ts b/UI/Web/src/app/_models/card/card-configuration.ts new file mode 100644 index 000000000..c3623f81d --- /dev/null +++ b/UI/Web/src/app/_models/card/card-configuration.ts @@ -0,0 +1,165 @@ +import {DownloadEvent} from "../../shared/_services/download.service"; +import {Observable} from "rxjs"; +import {BulkSelectionEntityDataSource} from "../../cards/bulk-selection.service"; +import {CardEntity} from "./card-entity"; +import {MangaFormat} from "../manga-format"; +import {TemplateRef} from "@angular/core"; +import {ActionableEntity} from "../../_services/action-factory.service"; +import {IHasProgress} from "../common/i-has-progress"; +import {ActionItem} from "../actionables/action-item"; +import {UserProgressUpdateEvent} from "../events/user-progress-update-event"; + +/** + * Configuration object that defines how a card renders and behaves. + * Created by CardConfigFactory for each entity type with sensible defaults. + * + * @typeParam T - The underlying data type. + */ +export interface BaseCardConfiguration { + + /** Whether bulk selection is enabled for this card */ + allowSelection: boolean; + + /** Entity type identifier for bulk selection tracking */ + selectionType: BulkSelectionEntityDataSource; + + /** Suppress Archive Warning when page count is 0 **/ + suppressArchiveWarning: boolean; + + /** Returns the cover image URL */ + coverFunc: (entity: T) => string; + + /** Returns the primary title displayed in the card footer */ + titleFunc: (entity: T) => string; + + /** Returns the router link for the title */ + titleRouteFunc: (entity: T) => string; + + /** Returns the meta title text (area above the main title). Required as fallback. */ + metaTitleFunc: (entity: T, wrapper: CardEntity) => string; + + /** Returns tooltip text for the title */ + tooltipFunc: (entity: T) => string; + + /** Returns reading progress. Return { pages: 0, pagesRead: 0 } if not applicable. */ + progressFunc: (entity: T) => IHasProgress; + + /** Returns the MangaFormat for the format badge, or null to hide */ + formatBadgeFunc?: (entity: T) => MangaFormat | null; + + /** Returns count for the badge (e.g., volume count, file count). 0 or 1 hides the badge. */ + countFunc?: (entity: T) => number; + + /** Returns true to show the error banner ("cannot read"). Default: pages === 0 */ + showErrorFunc?: (entity: T) => boolean; + + /** Returns accessible label for the card */ + ariaLabelFunc?: (entity: T) => string; + + /** + * Optional template for title area. Takes precedence over titleFunc. + * Context: { $implicit: CardEntity } - the full wrapper, not just data + */ + titleTemplate?: TemplateRef<{ $implicit: CardEntity }>; + + /** + * Optional template for meta title area. Takes precedence over metaTitleFunc. + * Context: { $implicit: CardEntity } - the full wrapper, not just data + */ + metaTitleTemplate?: TemplateRef<{ $implicit: CardEntity }>; + + /** Callback when the read button is clicked */ + readFunc: (entity: T) => void; + + /** Callback when the card body is clicked (navigation or preview) */ + clickFunc?: (entity: T, wrapper: CardEntity) => void; + + /** + * Returns an observable of download events for this entity. + * Used to show download progress indicator. + */ + downloadObservableFunc?: (entity: T) => Observable; + /** + * Returns key/values for route params (bookmark mode) + */ + titleRouteParamsFunc?: (entity: T) => Record; + + /** + * Optional strategy for handling real-time progress updates. + * If not provided, the card ignores progress events. + */ + progressUpdateStrategy?: ProgressUpdateStrategy; +} + +/** + * Configuration object that defines how a card renders and behaves. + * Created by CardConfigFactory for each entity type with sensible defaults. + * + * @typeParam T - The underlying data type, T must be ActionableEntity + */ +export interface ActionableCardConfiguration + extends BaseCardConfiguration { + /** Action items for the card's action menu */ + actionableFunc: (entity: T) => ActionItem[]; +} + +export type CardConfiguration = T extends ActionableEntity + ? ActionableCardConfiguration | BaseCardConfiguration + : BaseCardConfiguration; + +export function hasActionables( + config: BaseCardConfiguration +): config is BaseCardConfiguration & { actionableFunc: (entity: any) => ActionItem[] } { + return ( + 'actionableFunc' in config && + typeof (config as any).actionableFunc === 'function' + ); +} + +/** + * Partial configuration for overrides. All properties optional. + */ +export type CardConfigurationOverrides = Partial>; +export type BaseCardConfigurationOverrides = Partial>; + + +/** + * Defines how a card entity matches and responds to real-time progress updates. + * The card component uses this to: + * 1. Filter relevant SignalR events + * 2. Apply local updates to the entity + * 3. Notify the parent via a callback for state synchronization + */ +export interface ProgressUpdateStrategy { + /** + * Extract matching criteria from the entity. + * Used to filter incoming UserProgressUpdateEvent. + */ + getMatchCriteria: (entity: T) => ProgressMatchCriteria; + + /** + * Apply the update to the entity and return the new state. + * Return null if the entity cannot be updated locally (e.g., series without chapter data) + * and requires a full refetch. + */ + applyUpdate: (entity: T, event: UserProgressUpdateEvent) => T | null; +} + +export interface ProgressMatchCriteria { + chapterId?: number; + volumeId?: number; + seriesId?: number; +} + +/** + * Result emitted after a progress update is processed. + * Parent components use this to update their state. + */ +export interface ProgressUpdateResult { + /** The updated entity (null if refetch required) */ + entity: T | null; + /** The original event that triggered the update */ + event: UserProgressUpdateEvent; + /** Whether the parent should refetch instead of using the entity */ + requiresRefetch: boolean; +} diff --git a/UI/Web/src/app/_models/card/card-entity.ts b/UI/Web/src/app/_models/card/card-entity.ts new file mode 100644 index 000000000..17a2fcb5b --- /dev/null +++ b/UI/Web/src/app/_models/card/card-entity.ts @@ -0,0 +1,167 @@ +import {ReadingList, ReadingListItem} from "../reading-list"; +import {Chapter} from "../chapter"; +import {Series} from "../series"; +import {RelationKind} from "../series-detail/relation-kind"; +import {Volume} from "../volume"; +import {UserCollection} from "../collection-tag"; +import {LibraryType} from "../library/library"; +import {PageBookmark} from "../readers/page-bookmark"; +import {RelatedSeriesPair} from "../../_single-module/related-tab/related-tab.component"; +import {SeriesGroup} from "../series-group"; + +/** + * Discriminated union representing any entity that can be displayed as a card. + * Backend returns entityType + data; UI patches in context properties as needed. + * + * Usage: + * if (entity.entityType === 'series') { + * // TypeScript knows entity.data is Series + * console.log(entity.data.libraryId); + * } + */ +export type CardEntity = + | SeriesCardEntity + | CollectionCardEntity + | ReadingListCardEntity + | VolumeCardEntity + | ChapterCardEntity + | ReadingListItemCardEntity + | BookmarkCardEntity + | RelatedSeriesCardEntity + | RecentlyUpdatedSeriesCardEntity; + +export interface SeriesCardEntity { + entityType: 'series'; + data: Series; + /** UI-patched: Relationship to another series when displayed in relations context */ + relation?: RelationKind; + /** UI-patched: Whether this card appears in the On Deck stream */ + isOnDeck?: boolean; +} + +export interface CollectionCardEntity { + entityType: 'collection'; + data: UserCollection; +} + +export interface ReadingListCardEntity { + entityType: 'readinglist'; + data: ReadingList; +} + +export interface VolumeCardEntity { + entityType: 'volume'; + data: Volume; + /** Required context for routing and actions */ + seriesId: number; + libraryId: number; +} + +export interface ChapterCardEntity { + entityType: 'chapter'; + data: Chapter; + /** Required context for routing and actions */ + seriesId: number; + libraryId: number; + /** UI-patched: Library type affects title rendering */ + libraryType?: LibraryType; + /** UI-patched: Suppresses "cannot read" warning for special cases */ + suppressArchiveWarning?: boolean; +} + +export interface ReadingListItemCardEntity { + entityType: 'readinglist-item'; + data: ReadingListItem; +} + +export interface BookmarkCardEntity { + entityType: 'bookmark'; + data: PageBookmark; +} + +export interface RelatedSeriesCardEntity { + entityType: 'related'; + data: RelatedSeriesPair; +} +export interface RecentlyUpdatedSeriesCardEntity { + entityType: 'recentlyUpdatedSeries'; + data: SeriesGroup; +} + +/** + * Type guard utilities for working with CardEntity + */ +export const CardEntityGuards = { + isSeries: (e: CardEntity): e is SeriesCardEntity => e.entityType === 'series', + isCollection: (e: CardEntity): e is CollectionCardEntity => e.entityType === 'collection', + isReadingList: (e: CardEntity): e is ReadingListCardEntity => e.entityType === 'readinglist', + isVolume: (e: CardEntity): e is VolumeCardEntity => e.entityType === 'volume', + isChapter: (e: CardEntity): e is ChapterCardEntity => e.entityType === 'chapter', + isReadingListItem: (e: CardEntity): e is ReadingListItemCardEntity => e.entityType === 'readinglist-item', + isBookmark: (e: CardEntity): e is BookmarkCardEntity => e.entityType === 'bookmark', + isRelatedSeries: (e: CardEntity): e is RelatedSeriesCardEntity => e.entityType === 'related', + isRecentlyUpdatedSeries: (e: CardEntity): e is RecentlyUpdatedSeriesCardEntity => e.entityType === 'recentlyUpdatedSeries', +}; + +/** + * Helper to extract the underlying data with proper typing + */ +export function getCardEntityData(entity: T): T['data'] { + return entity.data; +} + +/** + * Helper to create CardEntity wrappers (useful for UI patching) + */ +export const CardEntityFactory = { + series: (data: Series, context?: Partial>): SeriesCardEntity => ({ + entityType: 'series', + data, + ...context + }), + + collection: (data: UserCollection): CollectionCardEntity => ({ + entityType: 'collection', + data + }), + + readingList: (data: ReadingList): ReadingListCardEntity => ({ + entityType: 'readinglist', + data + }), + + volume: (data: Volume, seriesId: number, libraryId: number): VolumeCardEntity => ({ + entityType: 'volume', + data, + seriesId, + libraryId + }), + + chapter: (data: Chapter, seriesId: number, libraryId: number, context?: Partial>): ChapterCardEntity => ({ + entityType: 'chapter', + data, + seriesId, + libraryId, + ...context + }), + + readingListItem: (data: ReadingListItem): ReadingListItemCardEntity => ({ + entityType: 'readinglist-item', + data + }), + + bookmark: (data: PageBookmark): BookmarkCardEntity => ({ + entityType: 'bookmark', + data + }), + + related: (data: RelatedSeriesPair): RelatedSeriesCardEntity => ({ + entityType: 'related', + data + }), + + recentlyUpdatedSeries: (data: SeriesGroup): RecentlyUpdatedSeriesCardEntity => ({ + entityType: 'recentlyUpdatedSeries', + data + }), +}; diff --git a/UI/Web/src/app/_models/default-modal-options.ts b/UI/Web/src/app/_models/default-modal-options.ts deleted file mode 100644 index 4dbb391a9..000000000 --- a/UI/Web/src/app/_models/default-modal-options.ts +++ /dev/null @@ -1 +0,0 @@ -export const DefaultModalOptions = {scrollable: true, size: 'xl', fullscreen: 'xl'}; diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 85e66df6b..a5f0437ae 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -16,11 +16,17 @@ export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, Librar export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic]; export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel]; -export interface Library { +export interface LiteLibrary { + id: number; + name: string; + type: LibraryType; +} + +export interface Library extends LiteLibrary{ id: number; name: string; - lastScanned: string; type: LibraryType; + lastScanned: string; folders: string[]; coverImage?: string | null; folderWatching: boolean; diff --git a/UI/Web/src/app/_models/modal/modal-options.ts b/UI/Web/src/app/_models/modal/modal-options.ts new file mode 100644 index 000000000..a0c2bcb3c --- /dev/null +++ b/UI/Web/src/app/_models/modal/modal-options.ts @@ -0,0 +1,45 @@ +import {NgbModalOptions} from "@ng-bootstrap/ng-bootstrap"; + +export const DefaultModalOptions: Partial = { + scrollable: true, + size: 'xl', + fullscreen: 'xl', +}; + +/** Any Edit Entity modal should use this */ +export function editModal(): Partial { + return {...DefaultModalOptions, size: 'xl', fullscreen: 'xl'}; +} + +export function mediumModal(): Partial { + return {...DefaultModalOptions, size: 'md', fullscreen: 'sm'}; +} + +export function confirmModal(): Partial { + return {...DefaultModalOptions, size: 'lg', fullscreen: 'md'}; +} + +/** Any Add-To flow (Add to Reading List/Collection/etc) modal should use this. A thinned out modal. */ +export function addToModal(): Partial { + return {...DefaultModalOptions, size: 'md', fullscreen: 'sm'}; +} + +/** Non-dismissible — for refresh-required modals only */ +export function versionRefreshModal(): Partial { + return { + ...DefaultModalOptions, + size: 'lg', + keyboard: false, + scrollable: true, + backdrop: 'static' + }; +} + +/** Dismissible — for update-available and out-of-date modals */ +export function versionNotifyModal(): Partial { + return { + ...DefaultModalOptions, + size: 'lg', + scrollable: true, + }; +} diff --git a/UI/Web/src/app/_models/modal/modal-result.ts b/UI/Web/src/app/_models/modal/modal-result.ts new file mode 100644 index 000000000..1b6997e8e --- /dev/null +++ b/UI/Web/src/app/_models/modal/modal-result.ts @@ -0,0 +1,18 @@ +export interface ModalResult { + success: boolean; + data?: T; + coverImageUpdated?: boolean; + isDeleted?: boolean; +} + +export function modalSaved(data?: T, coverImageUpdated = false): ModalResult { + return { success: true, data, coverImageUpdated, isDeleted: false }; +} + +export function modalDeleted(data?: T): ModalResult { + return { success: true, data, coverImageUpdated: false, isDeleted: true }; +} + +export function modalCancelled(): ModalResult { + return { success: false }; +} diff --git a/UI/Web/src/app/_models/series-group.ts b/UI/Web/src/app/_models/series-group.ts index 76c09d135..5fc9841da 100644 --- a/UI/Web/src/app/_models/series-group.ts +++ b/UI/Web/src/app/_models/series-group.ts @@ -1,4 +1,5 @@ -import { LibraryType } from "./library/library"; +import {LibraryType} from "./library/library"; +import {MangaFormat} from "./manga-format"; export interface SeriesGroup { seriesId: number; @@ -9,6 +10,7 @@ export interface SeriesGroup { libraryType: LibraryType; volumeId: number; chapterId: number; + format: MangaFormat; id: number; // This is UI only, sent from backend but has no relation to any entity count: number; } diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 2978c04e4..17c00dc60 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -32,6 +32,7 @@ export interface Series extends IHasCover, IHasReadingTime, IHasProgress { userRating: number; hasUserRated: boolean; libraryId: number; + libraryName: string; /** * DateTime the entity was created */ diff --git a/UI/Web/src/app/_models/user/user.ts b/UI/Web/src/app/_models/user/user.ts index 1871e0a93..3398b5b2f 100644 --- a/UI/Web/src/app/_models/user/user.ts +++ b/UI/Web/src/app/_models/user/user.ts @@ -11,8 +11,6 @@ export interface User extends IHasCover { refreshToken: string; roles: string[]; preferences: Preferences; - // ApiKey is deprecated in favor of AuthKeys - //apiKey: string; email: string; ageRestriction: AgeRestriction; hasRunScrobbleEventGeneration: boolean; diff --git a/UI/Web/src/app/base-url.provider.ts b/UI/Web/src/app/_providers/base-url.provider.ts similarity index 100% rename from UI/Web/src/app/base-url.provider.ts rename to UI/Web/src/app/_providers/base-url.provider.ts diff --git a/UI/Web/src/app/_resolvers/chapter.resolver.ts b/UI/Web/src/app/_resolvers/chapter.resolver.ts new file mode 100644 index 000000000..54cfbe4e0 --- /dev/null +++ b/UI/Web/src/app/_resolvers/chapter.resolver.ts @@ -0,0 +1,23 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of} from "rxjs"; +import {ChapterService} from "../_services/chapter.service"; +import {Chapter} from "../_models/chapter"; + +export const chapterResolver: ResolveFn = (route, state) => { + const chapterService = inject(ChapterService); + const router = inject(Router); + + const chapterId = route.parent?.paramMap.get('chapterId') || route.paramMap.get('chapterId'); + + if (!chapterId || chapterId === '0') { + console.error('Chapter ID not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return chapterService.getChapterMetadata(parseInt(chapterId, 10)).pipe( + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/collection.resolver.ts b/UI/Web/src/app/_resolvers/collection.resolver.ts new file mode 100644 index 000000000..e212dc349 --- /dev/null +++ b/UI/Web/src/app/_resolvers/collection.resolver.ts @@ -0,0 +1,28 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of, switchMap} from "rxjs"; +import {UserCollection} from "../_models/collection-tag"; +import {CollectionTagService} from "../_services/collection-tag.service"; + +export const collectionResolver: ResolveFn = (route, state) => { + const collectionTagService = inject(CollectionTagService); + const router = inject(Router); + + const collectionId = route.paramMap.get('collectionId') || route.parent?.paramMap.get('collectionId'); + + if (!collectionId || collectionId === '0') { + return of(router.parseUrl('/collections')); + } + + return collectionTagService.getCollectionById(parseInt(collectionId, 10)).pipe( + switchMap(collection => { + if (collection === null) { + return of(router.parseUrl('/collections')); + } + return of(collection); + }), + catchError(() => { + return of(router.parseUrl('/collections')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/library.resolver.ts b/UI/Web/src/app/_resolvers/library.resolver.ts new file mode 100644 index 000000000..1544deec9 --- /dev/null +++ b/UI/Web/src/app/_resolvers/library.resolver.ts @@ -0,0 +1,28 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {Library} from "../_models/library/library"; +import {LibraryService} from "../_services/library.service"; +import {catchError, of} from "rxjs"; + +/** + * Get Library is an admin-restricted API, in the case the user is not an Admin, this will return a Library with just the id/type/name + * @param route + * @param state + */ +export const libraryResolver: ResolveFn = (route, state) => { + const libraryService = inject(LibraryService); + const router = inject(Router); + + const libId = route.parent?.paramMap.get('libraryId') || route.paramMap.get('libraryId'); + + if (!libId || libId === '0') { + console.error('Library ID not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return libraryService.getLibrary(parseInt(libId, 10)).pipe( + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/person.resolver.ts b/UI/Web/src/app/_resolvers/person.resolver.ts new file mode 100644 index 000000000..076bb94b6 --- /dev/null +++ b/UI/Web/src/app/_resolvers/person.resolver.ts @@ -0,0 +1,29 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of, switchMap} from "rxjs"; +import {PersonService} from "../_services/person.service"; +import {Person} from "../_models/metadata/person"; + +export const personResolver: ResolveFn = (route, state) => { + const personService = inject(PersonService); + const router = inject(Router); + + const personName = route.parent?.paramMap.get('name') || route.paramMap.get('name'); + + if (!personName || personName === '') { + console.error('Person Name not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return personService.get(personName).pipe( + switchMap(person => { + if (person === null) { + return of(router.parseUrl('/home')); + } + return of(person); + }), + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/reading-list.resolver.ts b/UI/Web/src/app/_resolvers/reading-list.resolver.ts new file mode 100644 index 000000000..7d98b28fa --- /dev/null +++ b/UI/Web/src/app/_resolvers/reading-list.resolver.ts @@ -0,0 +1,29 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of, switchMap} from "rxjs"; +import {ReadingList} from "../_models/reading-list"; +import {ReadingListService} from "../_services/reading-list.service"; + +export const readingListResolver: ResolveFn = (route, state) => { + const readingListService = inject(ReadingListService); + const router = inject(Router); + + const readingListId = route.paramMap.get('readingListId') || route.parent?.paramMap.get('readingListId'); + + if (!readingListId || readingListId === '0') { + console.error('Reading List ID not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return readingListService.getReadingList(parseInt(readingListId, 10)).pipe( + switchMap(readingList => { + if (readingList === null) { + return of(router.parseUrl('/home')); + } + return of(readingList); + }), + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/series.resolver.ts b/UI/Web/src/app/_resolvers/series.resolver.ts new file mode 100644 index 000000000..86a362c10 --- /dev/null +++ b/UI/Web/src/app/_resolvers/series.resolver.ts @@ -0,0 +1,24 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of} from "rxjs"; +import {SeriesService} from "../_services/series.service"; +import {Series} from "../_models/series"; + + +export const seriesResolver: ResolveFn = (route, state) => { + const seriesService = inject(SeriesService); + const router = inject(Router); + + const seriesId = route.parent?.paramMap.get('seriesId') || route.paramMap.get('seriesId'); + + if (!seriesId || seriesId === '0') { + console.error('Series ID not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return seriesService.getSeries(parseInt(seriesId, 10)).pipe( + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_resolvers/volume.resolver.ts b/UI/Web/src/app/_resolvers/volume.resolver.ts new file mode 100644 index 000000000..3b40726de --- /dev/null +++ b/UI/Web/src/app/_resolvers/volume.resolver.ts @@ -0,0 +1,23 @@ +import {ResolveFn, Router, UrlTree} from "@angular/router"; +import {inject} from "@angular/core"; +import {catchError, of} from "rxjs"; +import {Volume} from "../_models/volume"; +import {VolumeService} from "../_services/volume.service"; + +export const volumeResolver: ResolveFn = (route, state) => { + const volumeService = inject(VolumeService); + const router = inject(Router); + + const volumeId = route.parent?.paramMap.get('volumeId') || route.paramMap.get('volumeId'); + + if (!volumeId || volumeId === '0') { + console.error('Volume ID not found in route params or 0'); + return of(router.parseUrl('/home')); + } + + return volumeService.getVolumeMetadata(parseInt(volumeId, 10)).pipe( + catchError(() => { + return of(router.parseUrl('/home')); + }) + ); +}; diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index 5c4804251..7ff83c836 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -5,6 +5,7 @@ import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllSeriesComponent, pathMatch: 'full', + title: 'title.all-series', runGuardsAndResolvers: 'always', resolve: { filter: UrlFilterResolver diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 2c7c52036..0b7f7f2e9 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -4,6 +4,7 @@ import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: BookmarksComponent, pathMatch: 'full', + title: 'title.bookmarks', resolve: { filter: UrlFilterResolver }, diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts index ae5022bd1..51fe0b698 100644 --- a/UI/Web/src/app/_routes/browse-routing.module.ts +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -20,8 +20,8 @@ export const routes: Routes = [ }, runGuardsAndResolvers: 'always', }, - {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, - {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, + {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full', title: 'title.browse-genres'}, + {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full', title: 'title.browse-tags'}, {path: 'annotations', component: AllAnnotationsComponent, pathMatch: 'full', resolve: { filter: UrlFilterResolver, diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 2b3b0ffd7..83858a782 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -2,14 +2,16 @@ import {Routes} from '@angular/router'; import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; +import {collectionResolver} from "../_resolvers/collection.resolver"; export const routes: Routes = [ - {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent, + {path: '', component: AllCollectionsComponent, pathMatch: 'full', title: 'title.collections'}, + {path: ':collectionId', component: CollectionDetailComponent, + data: {titleField: 'collection', titleProp: 'title'}, resolve: { + collection: collectionResolver, filter: UrlFilterResolver }, runGuardsAndResolvers: 'always', }, ]; - diff --git a/UI/Web/src/app/_routes/dashboard-routing.module.ts b/UI/Web/src/app/_routes/dashboard-routing.module.ts index e035a47ea..bd9683b2a 100644 --- a/UI/Web/src/app/_routes/dashboard-routing.module.ts +++ b/UI/Web/src/app/_routes/dashboard-routing.module.ts @@ -6,5 +6,6 @@ export const routes: Routes = [ { path: '', component: DashboardComponent, + title: 'title.home', } ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 3c09a71ee..ba627e647 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,27 +1,17 @@ import {Routes} from '@angular/router'; -import {AuthGuard} from '../_guards/auth.guard'; -import {LibraryAccessGuard} from '../_guards/library-access.guard'; import {LibraryDetailComponent} from '../library-detail/library-detail.component'; import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; +import {libraryResolver} from "../_resolvers/library.resolver"; export const routes: Routes = [ - { - path: ':libraryId', - runGuardsAndResolvers: 'always', - canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent, - resolve: { - filter: UrlFilterResolver - }, - }, { path: '', - runGuardsAndResolvers: 'always', - canActivate: [AuthGuard, LibraryAccessGuard], component: LibraryDetailComponent, + data: {titleField: 'library', titleProp: 'name'}, resolve: { + library: libraryResolver, filter: UrlFilterResolver - }, - }, + } + } ]; diff --git a/UI/Web/src/app/_routes/person-detail-routing.module.ts b/UI/Web/src/app/_routes/person-detail-routing.module.ts deleted file mode 100644 index 95b610cea..000000000 --- a/UI/Web/src/app/_routes/person-detail-routing.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import {PersonDetailComponent} from "../person-detail/person-detail.component"; - - -export const routes: Routes = [ - { - path: ':name', - runGuardsAndResolvers: 'always', - canActivate: [AuthGuard], - component: PersonDetailComponent - }, - { - path: '', - runGuardsAndResolvers: 'always', - canActivate: [AuthGuard], - component: PersonDetailComponent - } -]; diff --git a/UI/Web/src/app/_routes/reading-list-routing.module.ts b/UI/Web/src/app/_routes/reading-list-routing.module.ts index f1c8e1410..1fc1e922c 100644 --- a/UI/Web/src/app/_routes/reading-list-routing.module.ts +++ b/UI/Web/src/app/_routes/reading-list-routing.module.ts @@ -1,9 +1,20 @@ -import { Routes } from "@angular/router"; -import { ReadingListDetailComponent } from "../reading-list/_components/reading-list-detail/reading-list-detail.component"; -import { ReadingListsComponent } from "../reading-list/_components/reading-lists/reading-lists.component"; - +import {Routes} from "@angular/router"; +import {ReadingListsComponent} from "../reading-list/_components/reading-lists/reading-lists.component"; +import {authGuard} from "../_guards/auth.guard"; +import {readingListResolver} from "../_resolvers/reading-list.resolver"; +// TODO: I can't figure out how to use this pattern and have the resolver work for readingList detail page. export const routes: Routes = [ - {path: '', component: ReadingListsComponent, pathMatch: 'full'}, - {path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'}, + { + path: '', + component: ReadingListsComponent, + pathMatch: 'full' + }, + { + path: ':readingListId', + runGuardsAndResolvers: 'always', + canActivate: [authGuard], + resolve: { readingList: readingListResolver }, + loadComponent: () => import('../reading-list/_components/reading-list-detail/reading-list-detail.component').then(c => c.ReadingListDetailComponent), + } ]; diff --git a/UI/Web/src/app/_routes/settings-routing.module.ts b/UI/Web/src/app/_routes/settings-routing.module.ts index 83cb040ef..02b8df3d3 100644 --- a/UI/Web/src/app/_routes/settings-routing.module.ts +++ b/UI/Web/src/app/_routes/settings-routing.module.ts @@ -2,5 +2,5 @@ import { Routes } from '@angular/router'; import {SettingsComponent} from "../settings/_components/settings/settings.component"; export const routes: Routes = [ - {path: '', component: SettingsComponent, pathMatch: 'full'}, + {path: '', component: SettingsComponent, pathMatch: 'full', title: 'title.settings'}, ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b593172c0..330a6d9d5 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -3,7 +3,7 @@ import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + {path: '', component: WantToReadComponent, pathMatch: 'full', title: 'title.want-to-read', runGuardsAndResolvers: 'always', resolve: { filter: UrlFilterResolver } }, diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6d3cf1fb5..43b506246 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,6 +1,6 @@ import {HttpClient} from '@angular/common/http'; -import {computed, DestroyRef, inject, Injectable} from '@angular/core'; -import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; +import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core'; +import {of} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; import {environment} from 'src/environments/environment'; import {Preferences} from '../_models/preferences/preferences'; @@ -12,12 +12,12 @@ import {UserUpdateEvent} from '../_models/events/user-update-event'; import {AgeRating} from '../_models/metadata/age-rating'; import {AgeRestriction} from '../_models/metadata/age-restriction'; import {TextResonse} from '../_types/text-response'; -import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; -import {Action} from "./action-factory.service"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {LicenseService} from "./license.service"; import {LocalizationService} from "./localization.service"; import {Annotation} from "../book-reader/_models/annotations/annotation"; -import {AuthKey, OpdsName} from "../_models/user/auth-key"; +import {AuthKey, ImageOnlyName, OpdsName} from "../_models/user/auth-key"; +import {Action} from "../_models/actionables/action"; export enum Role { Admin = 'Admin', @@ -54,24 +54,34 @@ export class AccountService { private readonly messageHub = inject(MessageHubService); baseUrl = environment.apiUrl; - userKey = 'kavita-user'; + public static userKey = 'kavita-user'; public static lastLoginKey = 'kavita-lastlogin'; public static localeKey = 'kavita-locale'; - private currentUser: User | undefined; - // Stores values, when someone subscribes gives (1) of last values seen. - private currentUserSource = new ReplaySubject(1); - public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); - public isAdmin$: Observable = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { - if (!u) return false; - return this.hasAdminRole(u); - }), shareReplay({bufferSize: 1, refCount: true})); - public readonly isAdmin = toSignal(this.isAdmin$); + private readonly _currentUser = signal(undefined); + public readonly currentUser = this._currentUser.asReadonly(); - public readonly currentUserSignal = toSignal(this.currentUser$); - public readonly userId = computed(() => this.currentUserSignal()?.id); - public readonly currentUserGenericApiKey = computed(() => this.currentUserSignal()?.authKeys.filter(k => k.name === OpdsName)[0].key); - public readonly isReadOnly = computed(() => this.currentUserSignal()?.roles.includes(Role.ReadOnly) ?? true); + // Derived signals + public readonly isLoggedIn = computed(() => this._currentUser() !== undefined); + public readonly userId = computed(() => this._currentUser()?.id); + public readonly username = computed(() => this._currentUser()?.username); + public readonly userPreferences = computed(() => this._currentUser()?.preferences); + + // Role signals + public readonly hasAdminRole = computed(() => this.hasRole(this._currentUser(), Role.Admin)); + public readonly hasChangePasswordRole = computed(() => this.hasRole(this._currentUser(), Role.ChangePassword)); + public readonly hasChangeAgeRestrictionRole = computed(() => this.hasRole(this._currentUser(), Role.ChangeRestriction)); + public readonly hasDownloadRole = computed(() => this.hasRole(this._currentUser(), Role.Download)); + public readonly hasBookmarkRole = computed(() => this.hasRole(this._currentUser(), Role.Bookmark)); + public readonly hasReadOnlyRole = computed(() => this._currentUser() ? this.hasRole(this._currentUser(), Role.ReadOnly) : true); + public readonly hasPromoteRole = computed(() => this.hasRole(this._currentUser(), Role.Promote) || this.hasRole(this._currentUser(), Role.Admin)); + + public readonly currentUserGenericApiKey = computed(() => + this._currentUser()?.authKeys?.find(k => k.name === OpdsName)?.key + ); + public readonly currentUserImageAuthKey = computed(() => + this._currentUser()?.authKeys?.find(k => k.name === ImageOnlyName)?.key + ); /** * SetTimeout handler for keeping track of refresh token call @@ -83,7 +93,7 @@ export class AccountService { constructor() { this.messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), map(evt => evt.payload as UserUpdateEvent), - filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), + filter(userUpdateEvent => userUpdateEvent.userName === this._currentUser()?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); @@ -91,17 +101,14 @@ export class AccountService { filter(evt => evt.event === EVENTS.AuthKeyUpdate), map(evt => evt.payload as {authKey: AuthKey}), tap(({authKey}) => { - const existingKeys = this.currentUser!.authKeys; + const existing = this._currentUser(); + if (!existing) return; + const existingKeys = existing.authKeys ?? []; const index = existingKeys.findIndex(k => k.id === authKey.id); - const authKeys = index >= 0 ? existingKeys.map(k => k.id === authKey.id ? authKey : k) : [...existingKeys, authKey]; - - this.setCurrentUser({ - ...this.currentUser!, - authKeys: authKeys, - }, false); + this.setCurrentUser({ ...existing, authKeys }, false); }), ).subscribe(); @@ -109,12 +116,9 @@ export class AccountService { filter(evt => evt.event === EVENTS.AuthKeyDeleted), map(evt => evt.payload as {id: number}), tap(({id}) => { - const authKeys = this.currentUser!.authKeys.filter(k => k.id !== id); - - this.setCurrentUser({ - ...this.currentUser!, - authKeys: authKeys, - }, false); + const existing = this._currentUser(); + if (!existing) return; + this.setCurrentUser({ ...existing, authKeys: (existing.authKeys ?? []).filter(k => k.id !== id) }, false); }), ).subscribe(); @@ -129,10 +133,17 @@ export class AccountService { }); } + canCurrentUserInvokeAction(action: Action) { + const user = this.currentUser(); + if (!user) return false; + + return this.canInvokeAction(user, action); + } + canInvokeAction(user: User, action: Action) { - const isAdmin = this.hasAdminRole(user); - const canDownload = this.hasDownloadRole(user); - const canPromote = this.hasPromoteRole(user); + const isAdmin = this.hasRole(user, Role.Admin); + const canDownload = this.hasRole(user, Role.Download); + const canPromote = this.hasRole(user, Role.Promote) || this.hasRole(user, Role.Admin); if (isAdmin) return true; if (action === Action.Download) return canDownload; @@ -141,6 +152,10 @@ export class AccountService { return true; } + hasRole(user: User | undefined, role: Role) { + return !!user && user.roles.includes(role); + } + /** * If the user has any role in the restricted roles array or is an Admin * @param user @@ -153,7 +168,7 @@ export class AccountService { } // If the user is an admin, they have the role - if (this.hasAdminRole(user)) { + if (this.hasRole(user, Role.Admin)) { return true; } @@ -171,71 +186,12 @@ export class AccountService { return roles.some(role => user.roles.includes(role)); } - /** - * If User or Admin, will return false - * @param user - * @param restrictedRoles - */ - hasAnyRestrictedRole(user: User, restrictedRoles: Array = []) { - if (!user || !user.roles) { - return true; - } - - if (restrictedRoles.length === 0) { - return false; - } - - // If the user is an admin, they have the role - if (this.hasAdminRole(user)) { - return false; - } - - - if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { - return true; - } - - return false; - } - - hasAdminRole(user: User) { - return user && user.roles.includes(Role.Admin); - } - - hasChangePasswordRole(user: User) { - return user && user.roles.includes(Role.ChangePassword); - } - - hasChangeAgeRestrictionRole(user: User) { - return user && !user.roles.includes(Role.Admin) && user.roles.includes(Role.ChangeRestriction); - } - - hasDownloadRole(user: User) { - return user && user.roles.includes(Role.Download); - } - - hasBookmarkRole(user: User) { - return user && user.roles.includes(Role.Bookmark); - } - - hasReadOnlyRole(user: User) { - return user && user.roles.includes(Role.ReadOnly); - } - - hasPromoteRole(user: User) { - return user && user.roles.includes(Role.Promote) || user.roles.includes(Role.Admin); - } - - getRoles() { - return this.httpClient.get(this.baseUrl + 'account/roles'); - } - /** * Should likes be displayed for the given annotation * @param annotation */ showAnnotationLikes(annotation: Annotation) { - const user = this.currentUserSignal(); + const user = this._currentUser(); if (!user) return false; const shareAnnotations = user.preferences.socialPreferences.shareAnnotations; @@ -250,7 +206,7 @@ export class AccountService { * @private */ private isSocialFeatureEnabled(feature: boolean, activeLibrary: number, ageRating: AgeRating) { - const user = this.currentUserSignal(); + const user = this._currentUser(); if (!user || !feature) return false; const socialPreferences = user.preferences.socialPreferences; @@ -295,42 +251,45 @@ export class AccountService { ); } - setCurrentUser(user?: User, refreshConnections = true) { + /** Omit auth keys since they are long-lived. The auth keys will be set from refreshAccount() */ + private getPersistableUser(user: User): Omit { + const { authKeys, ...persistable } = user; + return persistable; + } + + setCurrentUser(user?: User, refreshConnections = true) { + const currentUser = this._currentUser(); + const isSameUser = !!currentUser && !!user && currentUser.username === user.username; - const isSameUser = this.currentUser === user; if (user) { - localStorage.setItem(this.userKey, JSON.stringify(user)); + localStorage.setItem(AccountService.userKey, JSON.stringify(this.getPersistableUser(user))); localStorage.setItem(AccountService.lastLoginKey, user.username); } - this.currentUser = user; - this.currentUserSource.next(user); + this._currentUser.set(user); if (!refreshConnections) return; this.stopRefreshTokenTimer(); - if (this.currentUser) { - // BUG: StopHubConnection has a promise in it, this needs to be async - // But that really messes everything up + if (user) { if (!isSameUser) { this.messageHub.stopHubConnection(); - this.messageHub.createHubConnection(this.currentUser); + this.messageHub.createHubConnection(user); this.licenseService.hasValidLicense().subscribe(); } - if (this.currentUser.token) { + if (user.token) { this.startRefreshTokenTimer(); } } } logout(skipAutoLogin: boolean = false) { - const user = this.currentUserSignal(); + const user = this._currentUser(); if (!user) return; - localStorage.removeItem(this.userKey); - this.currentUserSource.next(undefined); - this.currentUser = undefined; + localStorage.removeItem(AccountService.userKey); + this._currentUser.set(undefined); this.stopRefreshTokenTimer(); this.messageHub.stopHubConnection(); @@ -403,10 +362,6 @@ export class AccountService { return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, TextResonse); } - getDecodedToken(token: string) { - return JSON.parse(atob(token.split('.')[1])); - } - requestResetPasswordEmail(email: string) { return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse); } @@ -437,32 +392,28 @@ export class AccountService { */ getPreferences() { return this.httpClient.get(this.baseUrl + 'users/get-preferences').pipe(map(pref => { - if (this.currentUser !== undefined && this.currentUser !== null) { - this.currentUser.preferences = pref; - this.setCurrentUser(this.currentUser); - } + const current = this._currentUser(); + if (current) this.setCurrentUser({ ...current, preferences: pref }); return pref; }), takeUntilDestroyed(this.destroyRef)); } updatePreferences(userPreferences: Preferences) { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { - if (this.currentUser !== undefined && this.currentUser !== null) { - this.currentUser.preferences = settings; - this.setCurrentUser(this.currentUser, false); + const current = this._currentUser(); + if (current) { + this.setCurrentUser({ ...current, preferences: settings }, false); // Update the locale on disk (for logout and compact-number pipe) - localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); - this.localizationService.refreshTranslations(this.currentUser.preferences.locale); - + localStorage.setItem(AccountService.localeKey, settings.locale); + this.localizationService.refreshTranslations(settings.locale); } return settings; }), takeUntilDestroyed(this.destroyRef)); } getUserFromLocalStorage(): User | undefined { - - const userString = localStorage.getItem(this.userKey); + const userString = localStorage.getItem(AccountService.userKey); if (userString) { return JSON.parse(userString) @@ -494,30 +445,23 @@ export class AccountService { refreshAccount() { - if (this.currentUser === null || this.currentUser === undefined) return of(); + if (!this._currentUser()) return of(); return this.httpClient.get(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => { - if (user) { - this.currentUser = {...user}; - } - - this.setCurrentUser(this.currentUser); + if (user) this.setCurrentUser({ ...user }); return user; })); } private refreshToken() { - if (this.currentUser === null || this.currentUser === undefined || !this.isOnline || !this.currentUser.token) return of(); + const current = this._currentUser(); + if (!current || !this.isOnline || !current.token) return of(); return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', - {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { - if (this.currentUser) { - this.currentUser.token = user.token; - this.currentUser.refreshToken = user.refreshToken; - } - - this.setCurrentUser(this.currentUser); - return user; + {token: current.token, refreshToken: current.refreshToken}).pipe(map(tokens => { + const updated = this._currentUser(); + if (updated) this.setCurrentUser({ ...updated, token: tokens.token, refreshToken: tokens.refreshToken }); + return tokens; })); } @@ -525,7 +469,7 @@ export class AccountService { * Every 10 mins refresh the token */ private startRefreshTokenTimer() { - if (this.currentUser === null || this.currentUser === undefined) { + if (!this._currentUser()) { this.stopRefreshTokenTimer(); return; } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index f404d0976..cebccabf0 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,9 +1,9 @@ -import {inject, Injectable} from '@angular/core'; -import {map, Observable, shareReplay} from 'rxjs'; +import {effect, inject, Injectable} from '@angular/core'; +import {EMPTY, map, shareReplay} from 'rxjs'; import {Chapter} from '../_models/chapter'; import {UserCollection} from '../_models/collection-tag'; import {Device} from '../_models/device/device'; -import {Library} from '../_models/library/library'; +import {Library, LibraryType} from '../_models/library/library'; import {ReadingList} from '../_models/reading-list'; import {Series} from '../_models/series'; import {Volume} from '../_models/volume'; @@ -16,170 +16,17 @@ import {Person} from "../_models/metadata/person"; import {User} from '../_models/user/user'; import {Annotation} from "../book-reader/_models/annotations/annotation"; import {ClientDevice} from "../_models/client-device"; +import {PageBookmark} from "../_models/readers/page-bookmark"; +import {ActionService} from "./action.service"; +import {ActionItem, ActionShouldRenderFunc} from "../_models/actionables/action-item"; +import {Action} from "../_models/actionables/action"; +import {ActionResultCallback} from "../_models/actionables/action-result"; -export enum Action { - Submenu = -1, - /** - * Mark entity as read - */ - MarkAsRead = 0, - /** - * Mark entity as unread - */ - MarkAsUnread = 1, - /** - * Invoke a Scan on Series/Library - */ - Scan = 2, - /** - * Delete the entity - */ - Delete = 3, - /** - * Open edit modal - */ - Edit = 4, - /** - * Open details modal - */ - Info = 5, - /** - * Invoke a refresh covers - */ - RefreshMetadata = 6, - /** - * Download the entity - */ - Download = 7, - /** - * Invoke an Analyze Files which calculates word count - */ - AnalyzeFiles = 8, - /** - * Read in incognito mode aka no progress tracking - */ - IncognitoRead = 9, - /** - * Add to reading list - */ - AddToReadingList = 10, - /** - * Add to collection - */ - AddToCollection = 11, - /** - * Essentially a download, but handled differently. Needed so card bubbles it up for handling - */ - DownloadBookmark = 12, - /** - * Open Series detail page for said series - */ - ViewSeries = 13, - /** - * Open the reader for entity - */ - Read = 14, - /** - * Add to user's Want to Read List - */ - AddToWantToReadList = 15, - /** - * Remove from user's Want to Read List - */ - RemoveFromWantToReadList = 16, - /** - * Send to a device - */ - SendTo = 17, - /** - * Import some data into Kavita - */ - Import = 18, - /** - * Removes the Series from On Deck inclusion - */ - RemoveFromOnDeck = 19, - AddRuleGroup = 20, - RemoveRuleGroup = 21, - MarkAsVisible = 22, - MarkAsInvisible = 23, - /** - * Promotes the underlying item (Reading List, Collection) - */ - Promote = 24, - UnPromote = 25, - /** - * Invoke refresh covers as false to generate colorscapes - */ - GenerateColorScape = 26, - /** - * Copy settings from one entity to another - */ - CopySettings = 27, - /** - * Match an entity with an upstream system - */ - Match = 28, - /** - * Merge two (or more?) entities - */ - Merge = 29, - /** - * Add to a reading profile - */ - SetReadingProfile = 30, - /** - * Remove the reading profile from the entity - */ - ClearReadingProfile = 31, - Export = 32, - Like = 33, - UnLike = 34, -} - -/** - * Callback for an action - */ -export type ActionCallback = (action: ActionItem, entity: T) => void; -export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; - -export interface ActionItem { - title: string; - description: string; - action: Action; - callback: ActionCallback; - /** - * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. - */ - requiredRoles: Role[]; - /** - * @deprecated Use required Roles instead - */ - requiresAdmin?: boolean; - children: Array>; - /** - * An optional class which applies to an item. ie) danger on a delete action - */ - class?: string; - /** - * Indicates that there exists a separate list will be loaded from an API. - * Rule: If using this, only one child should exist in children with the Action for dynamicList. - */ - dynamicList?: Observable<{title: string, data: any}[]> | undefined; - /** - * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return - */ - _extra?: {title: string, data: any}; - /** - * Will call on each action to determine if it should show for the appropriate entity based on state and user - */ - shouldRender: ActionShouldRenderFunc; -} /** * Entities that can be actioned upon */ -export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | ClientDevice | null; +export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | ClientDevice | PageBookmark | null; @Injectable({ providedIn: 'root', @@ -187,6 +34,7 @@ export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCol export class ActionFactoryService { private accountService = inject(AccountService); private deviceService = inject(DeviceService); + private actionService = inject(ActionService); private libraryActions: Array> = []; private seriesActions: Array> = []; @@ -194,78 +42,158 @@ export class ActionFactoryService { private chapterActions: Array> = []; private collectionTagActions: Array> = []; private readingListActions: Array> = []; - private bookmarkActions: Array> = []; + private bookmarkActions: Array> = []; private personActions: Array> = []; private sideNavStreamActions: Array> = []; private smartFilterActions: Array> = []; - private sideNavHomeActions: Array> = []; + private sideNavHomeActions: Array> = []; private annotationActions: Array> = []; private clientDeviceActions: Array> = []; constructor() { - this.accountService.currentUser$.subscribe((_) => { + this._resetActions(); + effect(() => { + this.accountService.currentUser(); this._resetActions(); }); } - getLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem[]; + getLibraryActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.libraryActions, + (action, entity) => this.actionService.handleLibraryAction(action, entity), + shouldRenderFunc + ); } - getSeriesActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { - return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc); + getSeriesActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.seriesActions, + (action, entity) => this.actionService.handleSeriesAction(action, entity), + shouldRenderFunc + ); } - getSideNavStreamActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc); + getVolumeActions(seriesId: number, libraryId: number, libraryType: LibraryType, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.volumeActions, + (action, entity) => this.actionService.handleVolumeAction(action, entity, seriesId, libraryId, libraryType), + shouldRenderFunc + ); } - getSmartFilterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc); + getChapterActions(seriesId: number, libraryId: number, libraryType: LibraryType, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.chapterActions, + (action, entity) => this.actionService.handleChapterAction(action, entity, seriesId, libraryId, libraryType), + shouldRenderFunc + ); } - getVolumeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { - return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc); + getBookmarkActions(contextFunc: () => {seriesId: number, libraryId: number, seriesName: string}, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.bookmarkActions, + (action, entity) => this.actionService.handleBookmarkAction(action, entity, contextFunc), + shouldRenderFunc + ); } - getChapterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { - return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc); + getReadingListActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.readingListActions, + (action, entity) => this.actionService.handleReadingListAction(action, entity), + shouldRenderFunc + ); } - getCollectionTagActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc); + getCollectionTagActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.collectionTagActions, + (action, entity) => this.actionService.handleCollectionAction(action, entity), + shouldRenderFunc + ); } - getReadingListActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc); + getAnnotationActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.annotationActions, + (action, entity) => this.actionService.handleAnnotationAction(action, entity), + shouldRenderFunc + ); } - getBookmarkActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc); + getClientDeviceActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.clientDeviceActions, + (action, entity) => this.actionService.handleClientDeviceAction(action, entity), + shouldRenderFunc + ); } - getPersonActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc); + getPersonActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.personActions, + (action, entity) => this.actionService.handlePersonAction(action, entity), + shouldRenderFunc + ); } - getSideNavHomeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); + getSmartFilterActions(allFilters: SmartFilter[], shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.smartFilterActions, + (action, entity) => this.actionService.handleSmartFilterAction(action, entity, allFilters), + shouldRenderFunc + ); } - getAnnotationActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.annotationActions, callback, shouldRenderFunc); + + getSideNavStreamActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList( + this.sideNavStreamActions, + (action, entity) => this.actionService.handleSideNavStreamAction(action, entity), + shouldRenderFunc + ); } - getClientDeviceActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - return this.applyCallbackToList(this.clientDeviceActions, callback, shouldRenderFunc); + getSideNavHomeActions(shouldRenderFunc: ActionShouldRenderFunc<{}> = this.basicReadRender) { + return this.applyCallbackToList( + this.sideNavHomeActions, + (action, entity) => this.actionService.handleSideNavHomeStream(action, entity), + shouldRenderFunc + ); } - dummyCallback(action: ActionItem, entity: any) {} + getBulkLibraryActions(shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + + const filteredActions = this.flattenActions(this.libraryActions).filter(a => { + return [Action.Delete, Action.GenerateColorScape, Action.RefreshMetadata, Action.CopySettings].includes(a.action); + }); + + filteredActions.push({ + _extra: undefined, + class: undefined, + description: '', + dynamicList: undefined, + action: Action.CopySettings, + callback: this.dummyCallback, + shouldRender: shouldRenderFunc, + children: [], + requiredRoles: [Role.Admin], + title: 'copy-settings' + }); + + return this.applyCallbackToList( + filteredActions, + (action, entity) => this.actionService.handleBulkLibraryAction(action, entity), + shouldRenderFunc + ); + } + + dummyCallback(action: ActionItem, entity: any) { return EMPTY; } dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} basicReadRender(action: ActionItem, entity: any, user: User) { if (entity === null || entity === undefined) return true; if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true; - switch (action.action) { case(Action.MarkAsRead): return entity.pagesRead < entity.pages; @@ -276,13 +204,6 @@ export class ActionFactoryService { } } - filterSendToAction(actions: Array>, chapter: Chapter) { - // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { - // // Remove Send To as it doesn't apply - // return actions.filter(item => item.title !== 'Send To'); - // } - return actions; - } getActionablesForSettingsPage(actions: Array>, blacklist: Array = []) { const tasks = []; @@ -317,29 +238,6 @@ export class ActionFactoryService { return tasks.filter(t => !blacklist.includes(t.action)); } - getBulkLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { - - // Scan is currently not supported due to the backend not being able to handle it yet - const actions = this.flattenActions(this.libraryActions).filter(a => { - return [Action.Delete, Action.GenerateColorScape, Action.RefreshMetadata, Action.CopySettings].includes(a.action); - }); - - actions.push({ - _extra: undefined, - class: undefined, - description: '', - dynamicList: undefined, - action: Action.CopySettings, - callback: this.dummyCallback, - shouldRender: shouldRenderFunc, - children: [], - requiredRoles: [Role.Admin], - requiresAdmin: true, - title: 'copy-settings' - }) - return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem[]; - } - flattenActions(actions: Array>): Array> { return actions.reduce>>((flatArray, action) => { if (action.action !== Action.Submenu) { @@ -362,9 +260,9 @@ export class ActionFactoryService { action: Action.Scan, title: 'scan-library', description: 'scan-library-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, requiredRoles: [Role.Admin], children: [], }, @@ -372,18 +270,18 @@ export class ActionFactoryService { action: Action.Submenu, title: 'reading-profiles', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, requiredRoles: [], children: [ { action: Action.SetReadingProfile, title: 'set-reading-profile', description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, requiredRoles: [], children: [], }, @@ -391,9 +289,10 @@ export class ActionFactoryService { action: Action.ClearReadingProfile, title: 'clear-reading-profile', description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -403,18 +302,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'others', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -422,9 +323,10 @@ export class ActionFactoryService { action: Action.GenerateColorScape, title: 'generate-colorscape', description: 'generate-colorscape-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -432,9 +334,10 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -444,9 +347,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'settings', description: 'settings-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -457,9 +361,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', description: 'edit-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -467,9 +372,10 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], @@ -478,9 +384,10 @@ export class ActionFactoryService { action: Action.Promote, title: 'promote', description: 'promote-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -488,9 +395,10 @@ export class ActionFactoryService { action: Action.UnPromote, title: 'unpromote', description: 'unpromote-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -501,9 +409,10 @@ export class ActionFactoryService { action: Action.MarkAsRead, title: 'mark-as-read', description: 'mark-as-read-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -511,9 +420,10 @@ export class ActionFactoryService { action: Action.MarkAsUnread, title: 'mark-as-unread', description: 'mark-as-unread-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -521,9 +431,10 @@ export class ActionFactoryService { action: Action.Scan, title: 'scan-series', description: 'scan-series-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -531,18 +442,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'add-to', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -550,9 +463,10 @@ export class ActionFactoryService { action: Action.RemoveFromWantToReadList, title: 'remove-from-want-to-read', description: 'remove-to-want-to-read-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -560,9 +474,10 @@ export class ActionFactoryService { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -570,9 +485,10 @@ export class ActionFactoryService { action: Action.AddToCollection, title: 'add-to-collection', description: 'add-to-collection-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], } @@ -582,18 +498,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'send-to', description: 'send-to-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; @@ -606,18 +524,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'reading-profiles', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SetReadingProfile, title: 'set-reading-profile', description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -625,9 +545,10 @@ export class ActionFactoryService { action: Action.ClearReadingProfile, title: 'clear-reading-profile', description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -637,18 +558,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'others', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -656,9 +579,10 @@ export class ActionFactoryService { action: Action.GenerateColorScape, title: 'generate-colorscape', description: 'generate-colorscape-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -666,9 +590,10 @@ export class ActionFactoryService { action: Action.AnalyzeFiles, title: 'analyze-files', description: 'analyze-files-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -676,9 +601,10 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], class: 'danger', children: [], @@ -689,9 +615,10 @@ export class ActionFactoryService { action: Action.Match, title: 'match', description: 'match-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -699,9 +626,10 @@ export class ActionFactoryService { action: Action.Download, title: 'download', description: 'download-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, @@ -709,9 +637,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', description: 'edit-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -722,9 +651,10 @@ export class ActionFactoryService { action: Action.IncognitoRead, title: 'read-incognito', description: 'read-incognito-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -732,9 +662,10 @@ export class ActionFactoryService { action: Action.MarkAsRead, title: 'mark-as-read', description: 'mark-as-read-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -742,9 +673,10 @@ export class ActionFactoryService { action: Action.MarkAsUnread, title: 'mark-as-unread', description: 'mark-as-unread-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -752,18 +684,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'add-to', description: '=', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], } @@ -773,18 +707,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'send-to', description: 'send-to-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; @@ -797,18 +733,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'others', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -816,9 +754,10 @@ export class ActionFactoryService { action: Action.Download, title: 'download', description: 'download-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -828,9 +767,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'details', description: 'edit-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -841,9 +781,10 @@ export class ActionFactoryService { action: Action.IncognitoRead, title: 'read-incognito', description: 'read-incognito-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -851,9 +792,10 @@ export class ActionFactoryService { action: Action.MarkAsRead, title: 'mark-as-read', description: 'mark-as-read-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -861,9 +803,10 @@ export class ActionFactoryService { action: Action.MarkAsUnread, title: 'mark-as-unread', description: 'mark-as-unread-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -871,18 +814,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'add-to', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], } @@ -892,18 +837,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'send-to', description: 'send-to-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; @@ -917,18 +864,20 @@ export class ActionFactoryService { action: Action.Submenu, title: 'others', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -936,9 +885,10 @@ export class ActionFactoryService { action: Action.Download, title: 'download', description: 'download-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, @@ -948,9 +898,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', description: 'edit-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -961,9 +912,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', description: 'edit-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -971,9 +923,10 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], @@ -982,9 +935,10 @@ export class ActionFactoryService { action: Action.Promote, title: 'promote', description: 'promote-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -992,9 +946,10 @@ export class ActionFactoryService { action: Action.UnPromote, title: 'unpromote', description: 'unpromote-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1005,9 +960,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', description: 'edit-person-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, @@ -1015,9 +971,10 @@ export class ActionFactoryService { action: Action.Merge, title: 'merge', description: 'merge-person-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], } @@ -1028,19 +985,21 @@ export class ActionFactoryService { action: Action.ViewSeries, title: 'view-series', description: 'view-series-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, { - action: Action.DownloadBookmark, + action: Action.Download, title: 'download', description: 'download-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1048,10 +1007,11 @@ export class ActionFactoryService { action: Action.Delete, title: 'clear', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, class: 'danger', - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1062,9 +1022,10 @@ export class ActionFactoryService { action: Action.MarkAsVisible, title: 'mark-visible', description: 'mark-visible-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1072,9 +1033,10 @@ export class ActionFactoryService { action: Action.MarkAsInvisible, title: 'mark-invisible', description: 'mark-invisible-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1085,9 +1047,10 @@ export class ActionFactoryService { action: Action.Edit, title: 'rename', description: 'rename-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1095,9 +1058,10 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: 'delete-tooltip', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, + requiredRoles: [], children: [], }, @@ -1108,9 +1072,9 @@ export class ActionFactoryService { action: Action.Edit, title: 'reorder', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, - requiresAdmin: false, requiredRoles: [], children: [], } @@ -1121,6 +1085,7 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1130,6 +1095,7 @@ export class ActionFactoryService { action: Action.Export, title: 'export', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1139,6 +1105,7 @@ export class ActionFactoryService { action: Action.Like, title: 'like', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1148,6 +1115,7 @@ export class ActionFactoryService { action: Action.UnLike, title: 'unlike', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1160,6 +1128,7 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit-device-name', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1169,6 +1138,7 @@ export class ActionFactoryService { action: Action.Delete, title: 'delete', description: '', + callback: this.dummyCallback, shouldRender: this.dummyShouldRender, requiredRoles: [], @@ -1179,7 +1149,7 @@ export class ActionFactoryService { } - private applyCallback(action: ActionItem, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc) { + private applyCallback(action: ActionItem, callback: ActionResultCallback, shouldRenderFunc: ActionShouldRenderFunc) { action.callback = callback; action.shouldRender = shouldRenderFunc; @@ -1194,7 +1164,7 @@ export class ActionFactoryService { } public applyCallbackToList(list: Array>, - callback: ActionCallback, + callback: ActionResultCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { // Create a clone of the list to ensure we aren't affecting the default state const actions = list.map((a) => { @@ -1206,6 +1176,7 @@ export class ActionFactoryService { return actions; } + // Checks the whole tree for the action and returns true if it exists public hasAction(actions: Array>, action: Action) { if (actions.length === 0) return false; diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 929f261a9..a86e7ce57 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,9 +1,9 @@ import {inject, Injectable} from '@angular/core'; -import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; -import {take} from 'rxjs/operators'; -import {BulkAddToCollectionComponent} from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; -import {ADD_FLOW, AddToListModalComponent} from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; +import {catchError, finalize, map, take} from 'rxjs/operators'; +import {ListSelectModalComponent} from '../shared/_components/list-select-modal/list-select-modal.component'; +import {ScrobbleProvider} from './scrobbling.service'; import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; @@ -13,7 +13,7 @@ import { } from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; import {Chapter} from '../_models/chapter'; import {Device} from '../_models/device/device'; -import {Library} from '../_models/library/library'; +import {Library, LibraryType} from '../_models/library/library'; import {ReadingList} from '../_models/reading-list'; import {Series} from '../_models/series'; import {Volume} from '../_models/volume'; @@ -25,15 +25,42 @@ import {SeriesService} from './series.service'; import {translate} from "@jsverse/transloco"; import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; -import {FilterService} from "./filter.service"; import {ReadingListService} from "./reading-list.service"; import {ChapterService} from "./chapter.service"; import {VolumeService} from "./volume.service"; -import {DefaultModalOptions} from "../_models/default-modal-options"; import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component"; import { BulkSetReadingProfileModalComponent } from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component"; +import {EditSeriesModalComponent} from "../cards/_modals/edit-series-modal/edit-series-modal.component"; +import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component"; +import {DownloadService} from "../shared/_services/download.service"; +import {ReadingProfileService} from "./reading-profile.service"; +import {Action} from "../_models/actionables/action"; +import {ActionItem} from "../_models/actionables/action-item"; +import {EMPTY, filter, from, Observable, of, switchMap, tap} from "rxjs"; +import {ActionEffect, ActionResult} from "../_models/actionables/action-result"; +import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component"; +import {PageBookmark} from "../_models/readers/page-bookmark"; +import {Router} from "@angular/router"; +import { + EditCollectionTagsModalComponent +} from "../cards/_modals/edit-collection-tags/edit-collection-tags-modal.component"; +import {Annotation} from "../book-reader/_models/annotations/annotation"; +import {AnnotationService} from "./annotation.service"; +import {ClientDevice} from "../_models/client-device"; +import {Person} from "../_models/metadata/person"; +import {EditPersonModalComponent} from "../person-detail/_modal/edit-person-modal/edit-person-modal.component"; +import {MergePersonModalComponent} from "../person-detail/_modal/merge-person-modal/merge-person-modal.component"; +import {SmartFilter} from "../_models/metadata/v2/smart-filter"; +import { + EditSmartFilterModalComponent +} from "../sidenav/_components/edit-smart-filter-modal/edit-smart-filter-modal.component"; +import {SideNavStream} from "../_models/sidenav/sidenav-stream"; +import {NavService} from "./nav.service"; +import {ModalResult} from "../_models/modal/modal-result"; +import {addToModal, editModal} from "../_models/modal/modal-options"; +import {ModalService, TypedModalRef} from "./modal.service"; export type LibraryActionCallback = (library: Partial) => void; @@ -44,6 +71,7 @@ export type ReadingListActionCallback = (readingList: ReadingList) => void; export type VoidActionCallback = () => void; export type BooleanActionCallback = (result: boolean) => void; + /** * Responsible for executing actions */ @@ -58,17 +86,1195 @@ export class ActionService { private readonly seriesService = inject(SeriesService); private readonly readerService = inject(ReaderService); private readonly toastr = inject(ToastrService); - private readonly modalService = inject(NgbModal); + private readonly modalService = inject(ModalService); private readonly confirmService = inject(ConfirmService); private readonly memberService = inject(MemberService); private readonly deviceService = inject(DeviceService); private readonly collectionTagService = inject(CollectionTagService); - private readonly filterService = inject(FilterService); private readonly readingListService = inject(ReadingListService); + private readonly collectionService = inject(CollectionTagService); + private readonly downloadService = inject(DownloadService); + private readonly readingProfilesService = inject(ReadingProfileService); + private readonly router = inject(Router); + private readonly annotationsService = inject(AnnotationService); + private readonly sideNavService = inject(NavService); + + private readingListModalRef: TypedModalRef> | null = null; + private collectionModalRef: TypedModalRef> | null = null; - private readingListModalRef: NgbModalRef | null = null; - private collectionModalRef: NgbModalRef | null = null; + + // ------------------------------------------- + // MAIN HANDLERS + // ------------------------------------------- + + handleLibraryAction(action: ActionItem, library: Library) { + if (!library.hasOwnProperty('id') || library.id === undefined) { + return of(this.fromAction(action, library, 'none')); + } + + switch (action.action) { + case Action.Scan: + return this.libraryService.scan(library.id, false).pipe( + tap(() => this.toastr.info(translate('toasts.scan-queued', {name: library.name}))), + map(() => this.fromAction(action, library, 'none')) + ); + + case Action.RefreshMetadata: + return from(this.confirmService.confirm(translate('toasts.confirm-regen-covers'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.libraryService.refreshMetadata(library.id, true, false)), + tap(() => this.toastr.info(translate('toasts.refresh-covers-queued', {name: library.name}))), + map(() => this.fromAction(action, library, 'none')) + ); + + case Action.GenerateColorScape: + return this.libraryService.refreshMetadata(library.id, false, false).pipe( + tap(() => this.toastr.info(translate('toasts.generate-colorscape-queued', {name: library.name}))), + map(() => this.fromAction(action, library, 'none')) + ); + + case Action.Delete: + return from(this.confirmService.alert(translate('toasts.confirm-library-delete'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.libraryService.delete(library.id)), + tap(() => this.toastr.info(translate('toasts.library-deleted', {name: library.name}))), + map(() => this.fromAction(action, library, 'remove')) + ); + + case Action.Edit: { + const modalRef = this.modalService.open(LibrarySettingsModalComponent, editModal()); + modalRef.componentInstance.library = library; + return this.handleEditModal(modalRef, action, library); + } + + case Action.SetReadingProfile: + this.setReadingProfileForLibrary(library); + return of(this.fromAction(action, library, 'none')); + + case Action.ClearReadingProfile: + return this.readingProfilesService.clearLibraryProfiles(library.id).pipe( + tap(() => this.toastr.success(translate('actionable.cleared-profile'))), + map(() => this.fromAction(action, library, 'none')) + ); + + default: + return of(this.fromAction(action, library, 'none')); + } + } + + /** + * Centralized handler for all series actions. + * Returns Observable> so the caller can react to effects. + */ + handleSeriesAction(action: ActionItem, series: Series) { + switch (action.action) { + case Action.MarkAsRead: + return this.seriesService.markRead(series.id).pipe( + tap(() => this.toastr.success(translate('toasts.entity-read', {name: series.name}))), + map(() => this.fromAction(action, { ...series, pagesRead: series.pages }, 'update')) + ); + + case Action.MarkAsUnread: + return this.seriesService.markUnread(series.id).pipe( + tap(() => this.toastr.success(translate('toasts.entity-unread', {name: series.name}))), + map(() => this.fromAction(action, { ...series, pagesRead: 0 }, 'update')) + ); + + case Action.Scan: + return this.seriesService.scan(series.libraryId, series.id).pipe( + tap(() => this.toastr.info(translate('toasts.scan-queued', {name: series.name}))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.RefreshMetadata: + return from(this.confirmService.confirm(translate('toasts.confirm-regen-covers'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.seriesService.refreshMetadata(series, true, false)), + tap(() => this.toastr.info(translate('toasts.refresh-covers-queued', {name: series.name}))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.GenerateColorScape: + return this.seriesService.refreshMetadata(series, false, false).pipe( + tap(() => this.toastr.info(translate('toasts.generate-colorscape-queued', {name: series.name}))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.AnalyzeFiles: + return this.seriesService.analyzeFiles(series.libraryId, series.id).pipe( + tap(() => this.toastr.info(translate('toasts.scan-queued', {name: series.name}))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-series'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.seriesService.delete(series.id)), + tap(() => this.toastr.success(translate('toasts.series-deleted'))), + map(() => this.fromAction(action, series, 'remove')) + ); + + case Action.Edit: { + const modalRef = this.modalService.open(EditSeriesModalComponent, editModal()); + modalRef.componentInstance.series = series; + return this.handleEditModal(modalRef, action, series); + } + + case Action.Match: { + const ref = this.modalService.open(MatchSeriesModalComponent, editModal()); + ref.componentInstance.series = series; + return from(ref.closed).pipe( + filter((saved: boolean) => saved), + map(() => this.fromAction(action, series, 'reload')) + ); + } + + case Action.AddToReadingList: { + if (this.readingListModalRef != null) return EMPTY; + const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.readingListModalRef = rlRef; + + rlRef.setInput('title', series.name); + rlRef.setInput('showCreate', true); + rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label')); + rlRef.setInput('inputItems', []); + rlRef.setInput('loading', true); + rlRef.setInput('createInitialValue', series.name); + + this.readingListService.getReadingLists(false, true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => rlRef.setInput('loading', false)) + ).subscribe(result => { + rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l }))); + }); + + rlRef.setInput('interceptCreate', (name: string) => + this.readingListService.createList(name).pipe( + switchMap(list => this.readingListService.updateBySeries(list.id, series.id)), + tap(() => this.toastr.success(translate('toasts.series-added-to-reading-list'))) + ) + ); + + rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => { + const list = item as ReadingList; + this.readingListService.updateBySeries(list.id, series.id).subscribe(() => { + this.toastr.success(translate('toasts.series-added-to-reading-list')); + rlRef.close(); + }); + }); + + return new Observable>(subscriber => { + rlRef.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, series, 'none')); + subscriber.complete(); + }); + rlRef.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.AddToCollection: { + if (this.collectionModalRef != null) return EMPTY; + const colRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.collectionModalRef = colRef; + + const singleSeriesIds = [series.id]; + colRef.setInput('title', translate('bulk-add-to-collection.title')); + colRef.setInput('showCreate', true); + colRef.setInput('createLabel', translate('bulk-add-to-collection.collection-label')); + colRef.setInput('createInitialValue', translate('actionable.new-collection')); + colRef.setInput('inputItems', []); + colRef.setInput('loading', true); + + this.collectionService.allCollections(true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => colRef.setInput('loading', false)) + ).subscribe(tags => { + const collections = tags.filter(t => t.source === ScrobbleProvider.Kavita); + colRef.setInput('inputItems', collections.map(c => ({ label: c.title, value: c }))); + }); + + colRef.setInput('interceptCreate', (name: string) => + this.collectionService.addByMultiple(0, singleSeriesIds, name).pipe( + tap(() => this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: name }))) + ) + ); + + colRef.setInput('interceptConfirm', (item: UserCollection | UserCollection[]) => { + const tag = item as UserCollection; + this.collectionService.addByMultiple(tag.id, singleSeriesIds, '').subscribe(() => { + this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: tag.title })); + colRef.close(); + }); + }); + + return new Observable>(subscriber => { + colRef.closed.subscribe(() => { + this.collectionModalRef = null; + subscriber.next(this.fromAction(action, series, 'none')); + subscriber.complete(); + }); + colRef.dismissed.subscribe(() => { + this.collectionModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.Download: + this.downloadService.download('series', series); + return of(this.fromAction(action, series, 'none')); + + case Action.AddToWantToReadList: + return this.memberService.addSeriesToWantToRead([series.id]).pipe( + tap(() => this.toastr.success(translate('toasts.series-added-want-to-read'))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.RemoveFromWantToReadList: + return this.memberService.removeSeriesToWantToRead([series.id]).pipe( + tap(() => this.toastr.success(translate('toasts.series-removed-want-to-read'))), + map(() => this.fromAction(action, series, 'reload')) + ); + + case Action.RemoveFromOnDeck: + return this.seriesService.removeFromOnDeck(series.id).pipe( + map(() => this.fromAction(action, series, 'reload')) + ); + + case Action.SendTo: { + const device = action._extra!.data as Device; + return this.deviceService.sendSeriesToEmailDevice(series.id, device.id).pipe( + tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))), + map(() => this.fromAction(action, series, 'none')) + ); + } + + case Action.SetReadingProfile: + this.setReadingProfileForMultiple([series]); + return of(this.fromAction(action, series, 'none')); + + case Action.ClearReadingProfile: + return this.readingProfilesService.clearSeriesProfiles(series.id).pipe( + tap(() => this.toastr.success(translate('actionable.cleared-profile'))), + map(() => this.fromAction(action, series, 'none')) + ); + + default: + return of(this.fromAction(action, series, 'none')); + } + } + + /** + * Centralized handler for all volume actions. + * Returns Observable> so the caller can react to effects. + */ + handleVolumeAction(action: ActionItem, volume: Volume, seriesId: number, libraryId: number, libraryType: LibraryType) { + switch (action.action) { + case Action.MarkAsRead: + return this.readerService.markVolumeRead(seriesId, volume.id).pipe( + tap(() => this.toastr.success(translate('toasts.mark-read'))), + map(() => { + const updated = { + ...volume, + pagesRead: volume.pages, + chapters: volume.chapters?.map(c => ({...c, pagesRead: c.pages})) + }; + return this.fromAction(action, updated, 'update'); + }) + ); + + case Action.MarkAsUnread: + return this.readerService.markVolumeUnread(seriesId, volume.id).pipe( + tap(() => this.toastr.success(translate('toasts.mark-unread'))), + map(() => { + const updated = { + ...volume, + pagesRead: 0, + chapters: volume.chapters?.map(c => ({...c, pagesRead: 0})) + }; + return this.fromAction(action, updated, 'update'); + }) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-volume'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.volumeService.deleteVolume(volume.id)), + filter(success => success), + tap(() => this.toastr.success(translate('toasts.volume-deleted'))), + map(() => this.fromAction(action, volume, 'remove')) + ); + + case Action.Edit: { + const ref = this.modalService.open(EditVolumeModalComponent, editModal()); + ref.componentInstance.volume = volume; + ref.componentInstance.libraryType = libraryType; + ref.componentInstance.seriesId = seriesId; + ref.componentInstance.libraryId = libraryId; + return this.handleEditModal(ref, action, volume); + } + + case Action.AddToReadingList: { + if (this.readingListModalRef != null) return EMPTY; + const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.readingListModalRef = rlRef; + + rlRef.setInput('title', translate('add-to-list-modal.title')); + rlRef.setInput('showCreate', true); + rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label')); + rlRef.setInput('inputItems', []); + rlRef.setInput('loading', true); + + this.readingListService.getReadingLists(false, true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => rlRef.setInput('loading', false)) + ).subscribe(result => { + rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l }))); + }); + + rlRef.setInput('interceptCreate', (name: string) => + this.readingListService.createList(name).pipe( + switchMap(list => this.readingListService.updateByVolume(list.id, seriesId, volume.id)), + tap(() => this.toastr.success(translate('toasts.volumes-added-to-reading-list'))) + ) + ); + + rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => { + const list = item as ReadingList; + this.readingListService.updateByVolume(list.id, seriesId, volume.id).subscribe(() => { + this.toastr.success(translate('toasts.volumes-added-to-reading-list')); + rlRef.close(); + }); + }); + + return new Observable>(subscriber => { + rlRef.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, volume, 'none')); + subscriber.complete(); + }); + rlRef.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.IncognitoRead: + if (volume.chapters != undefined && volume.chapters.length >= 1) { + const sorted = [...volume.chapters].sort((a, b) => a.minNumber - b.minNumber); + this.readerService.readChapter(libraryId, seriesId, sorted[0], true); + } + return of(this.fromAction(action, volume, 'none')); + + case Action.SendTo: { + const device = action._extra!.data as Device; + return this.deviceService.sendToEmailDevice(volume.chapters.map(c => c.id), device.id).pipe( + tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))), + map(() => this.fromAction(action, volume, 'none')) + ); + } + + case Action.Download: + this.downloadService.download('volume', volume); + return of(this.fromAction(action, volume, 'none')); + + default: + return of(this.fromAction(action, volume, 'none')); + } + } + + /** + * Centralized handler for all chapter actions. + * Returns Observable> so the caller can react to effects. + */ + handleChapterAction(action: ActionItem, chapter: Chapter, seriesId: number, libraryId: number, libraryType: LibraryType) { + switch (action.action) { + + case Action.MarkAsRead: + return this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe( + tap(() => this.toastr.success(translate('toasts.mark-read'))), + map(() => { + const updated = { + ...chapter, + pagesRead: chapter.pages, + }; + return this.fromAction(action, updated, 'update'); + }) + ); + + case Action.MarkAsUnread: + return this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 9).pipe( + tap(() => this.toastr.success(translate('toasts.mark-unread'))), + map(() => { + const updated = { + ...chapter, + pagesRead: 0, + }; + return this.fromAction(action, updated, 'update'); + }) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.chapterService.deleteChapter(chapter.id)), + filter(success => success), + tap(() => this.toastr.success(translate('toasts.chapter-deleted'))), + map(() => this.fromAction(action, chapter, 'remove')) + ); + + case Action.Download: + this.downloadService.download('chapter', chapter); + return of(this.fromAction(action, chapter, 'none')); + + case Action.Edit: + const ref = this.modalService.open(EditChapterModalComponent, editModal()); + ref.componentInstance.chapter = chapter; + ref.componentInstance.libraryType = libraryType; + ref.componentInstance.seriesId = seriesId; + ref.componentInstance.libraryId = libraryId; + + return this.handleEditModal(ref, action, chapter); + + case Action.AddToReadingList: { + if (this.readingListModalRef != null) return EMPTY; + const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.readingListModalRef = rlRef; + + rlRef.setInput('title', translate('add-to-list-modal.title')); + rlRef.setInput('showCreate', true); + rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label')); + rlRef.setInput('inputItems', []); + rlRef.setInput('loading', true); + + this.readingListService.getReadingLists(false, true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => rlRef.setInput('loading', false)) + ).subscribe(result => { + rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l }))); + }); + + rlRef.setInput('interceptCreate', (name: string) => + this.readingListService.createList(name).pipe( + switchMap(list => this.readingListService.updateByChapter(list.id, seriesId, chapter.id)), + tap(() => this.toastr.success(translate('toasts.chapter-added-to-reading-list'))) + ) + ); + + rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => { + const list = item as ReadingList; + this.readingListService.updateByChapter(list.id, seriesId, chapter.id).subscribe(() => { + this.toastr.success(translate('toasts.chapter-added-to-reading-list')); + rlRef.close(); + }); + }); + + return new Observable>(subscriber => { + rlRef.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, chapter, 'none')); + subscriber.complete(); + }); + rlRef.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.IncognitoRead: + this.readerService.readChapter(libraryId, seriesId, chapter, true); + return of(this.fromAction(action, chapter, 'none')); + + case Action.SendTo: + const device = action._extra!.data as Device; + return this.deviceService.sendToEmailDevice([chapter.id], device.id).pipe( + tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))), + map(() => this.fromAction(action, chapter, 'none')) + ); + + default: + return of(this.fromAction(action, chapter, 'none')); + } + } + + /** + * Centralized handler for all bookmark actions. + * Returns Observable> so the caller can react to effects. + */ + handleBookmarkAction(action: ActionItem, bookmark: PageBookmark, contextFunc: () => {seriesId: number, libraryId: number, seriesName: string}) { + const ctx = contextFunc(); + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('bookmarks.confirm-single-delete', {seriesName: ctx.seriesName}))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.readerService.clearBookmarks(ctx.seriesId)), + tap(() => this.toastr.success(translate('bookmarks.delete-single-success'))), + map(() => this.fromAction(action, bookmark, 'remove')) + ); + + case Action.Download: + this.downloadService.download('bookmark', [bookmark]); + return of(this.fromAction(action, bookmark, 'none')); + + case Action.ViewSeries: + this.router.navigate(['library', ctx.libraryId, 'series', ctx.seriesId]); + return of(this.fromAction(action, bookmark, 'none')); + + default: + return of(this.fromAction(action, bookmark, 'none')); + } + } + + /** + * Centralized handler for all reading list actions. + * Returns Observable> so the caller can react to effects. + */ + handleReadingListAction(action: ActionItem, readingList: ReadingList) { + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.readingListService.delete(readingList.id)), + tap(() => this.toastr.success(translate('toasts.reading-list-deleted'))), + map(() => this.fromAction(action, readingList, 'remove')) + ); + + case Action.Edit: + const ref = this.modalService.open(EditReadingListModalComponent, editModal()); + ref.componentInstance.readingList = readingList; + return this.handleEditModal(ref, action, readingList); + case Action.Promote: + return this.readingListService.promoteMultipleReadingLists([readingList.id], true).pipe( + tap(() => this.toastr.success(translate('toasts.reading-list-promoted'))), + map(() => this.fromAction(action, {...readingList, promoted: true}, 'update')) + ); + + case Action.UnPromote: + return this.readingListService.promoteMultipleReadingLists([readingList.id], false).pipe( + tap(() => this.toastr.success(translate('toasts.reading-list-unpromoted'))), + map(() => this.fromAction(action, {...readingList, promoted: false}, 'update')) + ); + default: + return of(this.fromAction(action, readingList, 'none')); + } + } + + /** + * Centralized handler for all collection actions. + * Returns Observable> so the caller can react to effects. + */ + handleCollectionAction(action: ActionItem, collection: UserCollection) { + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-collection'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.collectionService.deleteTag(collection.id)), + tap(() => this.toastr.success(translate('toasts.collection-tag-deleted'))), + map(() => this.fromAction(action, collection, 'remove')) + ); + + case Action.Edit: + const ref = this.modalService.open(EditCollectionTagsModalComponent, editModal()); + ref.setInput('tag', collection); + return this.handleEditModal(ref, action, collection); + + case Action.Promote: + return this.collectionService.promoteMultipleCollections([collection.id], true).pipe( + tap(() => this.toastr.success(translate('toasts.collections-promoted'))), + map(() => this.fromAction(action, {...collection, promoted: true}, 'update')) + ); + + case Action.UnPromote: + return this.collectionService.promoteMultipleCollections([collection.id], false).pipe( + tap(() => this.toastr.success(translate('toasts.collections-unpromoted'))), + map(() => this.fromAction(action, {...collection, promoted: false}, 'update')) + ); + + default: + return of(this.fromAction(action, collection, 'none')); + } + } + + /** + * Centralized handler for all annotation actions. + * Returns Observable> so the caller can react to effects. + */ + handleAnnotationAction(action: ActionItem, annotation: Annotation) { + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-annotations'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.annotationsService.bulkDelete([annotation.id])), + tap(() => this.toastr.success(translate('toasts.annotations-deleted'))), + map(() => this.fromAction(action, annotation, 'remove')) + ); + + case Action.Export: + return this.annotationsService.exportAnnotations([annotation.id]).pipe( + map(() => this.fromAction(action, annotation, 'none')) + ); + + case Action.Like: + return this.annotationsService.likeAnnotations([annotation.id]).pipe( + map(() => this.fromAction(action, annotation, 'update')) + ); + + case Action.UnLike: + return this.annotationsService.unLikeAnnotations([annotation.id]).pipe( + map(() => this.fromAction(action, annotation, 'update')) + ); + + default: + return of(this.fromAction(action, annotation, 'none')); + } + } + + /** + * Centralized handler for all client device actions. + * Returns Observable> so the caller can react to effects. + */ + handleClientDeviceAction(action: ActionItem, clientDevice: ClientDevice) { + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-annotations'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.deviceService.deleteClientDevice(clientDevice.id)), + map((success) => this.fromAction(action, clientDevice, success ? 'remove' : 'none')) + ); + + case Action.Edit: + // Special case: This actually just triggers an edit toggle. Since there is no edit modal, we send update to handle + return of(this.fromAction(action, clientDevice, 'update')); + + default: + return of(this.fromAction(action, clientDevice, 'none')); + } + } + + /** + * Centralized handler for all person actions. + * Returns Observable> so the caller can react to effects. + */ + handlePersonAction(action: ActionItem, person: Person) { + switch (action.action) { + case Action.Edit: + const ref = this.modalService.open(EditPersonModalComponent, editModal()); + ref.componentInstance.person = person; + + return this.handleEditModal(ref, action, person); + + case Action.Merge: + const ref2 = this.modalService.open(MergePersonModalComponent, editModal()); + ref2.componentInstance.person = person; + + return from(ref2.closed).pipe( + filter((res: ModalResult) => res.success), + map((res: ModalResult) => + this.fromAction(action, person, res.success ? 'reload' : 'none') + ) + ); + default: + return of(this.fromAction(action, person, 'none')); + } + } + + /** + * Centralized handler for all smart filter actions. + * Returns Observable> so the caller can react to effects. + */ + handleSmartFilterAction(action: ActionItem, smartFilter: SmartFilter, allFilters: SmartFilter[]) { + switch (action.action) { + case Action.Edit: + const ref = this.modalService.open(EditSmartFilterModalComponent, editModal()); + ref.componentInstance.smartFilter = smartFilter; + ref.componentInstance.allFilters = allFilters; + return this.handleEditModal(ref, action, smartFilter); + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.collectionService.deleteTag(smartFilter.id)), + tap(() => this.toastr.success(translate('toasts.smart-filter-deleted'))), + map(() => this.fromAction(action, smartFilter, 'remove')) + ); + default: + return of(this.fromAction(action, smartFilter, 'none')); + } + } + + /** + * Centralized handler for all side nav stream actions. + * Returns Observable> so the caller can react to effects. + */ + handleSideNavStreamAction(action: ActionItem, sideNavStream: SideNavStream) { + switch (action.action) { + case Action.MarkAsVisible: + return this.sideNavService.bulkToggleSideNavStreamVisibility([sideNavStream.id], true).pipe( + map(() => this.fromAction(action, {...sideNavStream, visible: true}, 'update')) + ); + + case Action.MarkAsInvisible: + return this.sideNavService.bulkToggleSideNavStreamVisibility([sideNavStream.id], false).pipe( + map(() => this.fromAction(action, {...sideNavStream, visible: false}, 'update')) + ); + + default: + return of(this.fromAction(action, sideNavStream, 'none')); + } + } + + /** + * Centralized handler for all side nav home stream actions. + * Returns Observable> so the caller can react to effects. + */ + handleSideNavHomeStream(action: ActionItem<{}>, entity: {}) { + switch (action.action) { + case Action.Edit: + return of(this.fromAction(action, entity, 'none')); + + default: + return of(this.fromAction(action, entity, 'none')); + } + } + + /** + * Centralized handler for all bulk library actions. + * Returns Observable> so the caller can react to effects. + */ + handleBulkLibraryAction(action: ActionItem, library: Library) { + // manage-library handles all actions, the actionables don't perform as other implementations + return of(this.fromAction(action, library, 'none')); + } + + // ------------------------------------------- + // BULK HANDLERS + // ------------------------------------------- + + handleBulkSeriesAction(action: ActionItem, series: Series[]): Observable> { + switch (action.action) { + case Action.MarkAsRead: + return this.readerService.markMultipleSeriesRead(series.map(s => s.id)).pipe( + tap(() => { + series.forEach(s => s.pagesRead = s.pages); + this.toastr.success(translate('toasts.mark-read')); + }), + map(() => this.fromAction(action, series, 'update')) + ); + + case Action.MarkAsUnread: + return this.readerService.markMultipleSeriesUnread(series.map(s => s.id)).pipe( + tap(() => { + series.forEach(s => s.pagesRead = 0); + this.toastr.success(translate('toasts.mark-unread')); + }), + map(() => this.fromAction(action, series, 'update')) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: series.length}))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.seriesService.deleteMultipleSeries(series.map(s => s.id))), + tap(res => { + if (res) { + this.toastr.success(translate('toasts.series-deleted')); + } else { + this.toastr.error(translate('errors.generic')); + } + }), + filter(res => res), + map(() => this.fromAction(action, series, 'remove')) + ); + + case Action.AddToReadingList: { + if (this.readingListModalRef != null) return EMPTY; + const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.readingListModalRef = rlRef; + + const bulkSeriesIds = series.map(s => s.id); + rlRef.setInput('title', translate('actionable.multiple-selections')); + rlRef.setInput('showCreate', true); + rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label')); + rlRef.setInput('inputItems', []); + rlRef.setInput('loading', true); + + this.readingListService.getReadingLists(false, true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => rlRef.setInput('loading', false)) + ).subscribe(result => { + rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l }))); + }); + + rlRef.setInput('interceptCreate', (name: string) => + this.readingListService.createList(name).pipe( + switchMap(list => this.readingListService.updateByMultipleSeries(list.id, bulkSeriesIds)), + tap(() => this.toastr.success(translate('toasts.series-added-to-reading-list'))) + ) + ); + + rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => { + const list = item as ReadingList; + this.readingListService.updateByMultipleSeries(list.id, bulkSeriesIds).subscribe(() => { + this.toastr.success(translate('toasts.series-added-to-reading-list')); + rlRef.close(); + }); + }); + + return new Observable>(subscriber => { + rlRef.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, series, 'none')); + subscriber.complete(); + }); + rlRef.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.AddToCollection: { + if (this.collectionModalRef != null) return EMPTY; + const colRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.collectionModalRef = colRef; + + const bulkColSeriesIds = series.map(s => s.id); + colRef.setInput('title', translate('bulk-add-to-collection.title')); + colRef.setInput('showCreate', true); + colRef.setInput('createLabel', translate('bulk-add-to-collection.collection-label')); + colRef.setInput('createInitialValue', translate('actionable.new-collection')); + colRef.setInput('inputItems', []); + colRef.setInput('loading', true); + + this.collectionService.allCollections(true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => colRef.setInput('loading', false)) + ).subscribe(tags => { + const collections = tags.filter(t => t.source === ScrobbleProvider.Kavita); + colRef.setInput('inputItems', collections.map(c => ({ label: c.title, value: c }))); + }); + + colRef.setInput('interceptCreate', (name: string) => + this.collectionService.addByMultiple(0, bulkColSeriesIds, name).pipe( + tap(() => this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: name }))) + ) + ); + + colRef.setInput('interceptConfirm', (item: UserCollection | UserCollection[]) => { + const tag = item as UserCollection; + this.collectionService.addByMultiple(tag.id, bulkColSeriesIds, '').subscribe(() => { + this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: tag.title })); + colRef.close(); + }); + }); + + return new Observable>(subscriber => { + colRef.closed.subscribe(() => { + this.collectionModalRef = null; + subscriber.next(this.fromAction(action, series, 'none')); + subscriber.complete(); + }); + colRef.dismissed.subscribe(() => { + this.collectionModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.AddToWantToReadList: + return this.memberService.addSeriesToWantToRead(series.map(s => s.id)).pipe( + tap(() => this.toastr.success(translate('toasts.series-added-want-to-read'))), + map(() => this.fromAction(action, series, 'none')) + ); + + case Action.RemoveFromWantToReadList: + return this.memberService.removeSeriesToWantToRead(series.map(s => s.id)).pipe( + tap(() => this.toastr.success(translate('toasts.series-removed-want-to-read'))), + map(() => this.fromAction(action, series, 'reload')) + ); + + case Action.SetReadingProfile: { + if (this.readingListModalRef != null) return EMPTY; + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal()); + this.readingListModalRef.setInput('seriesIds', series.map(s => s.id)); + + const ref = this.readingListModalRef; + return new Observable>(subscriber => { + ref.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, series, 'none')); + subscriber.complete(); + }); + ref.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + default: + return of(this.fromAction(action, series, 'none')); + } + } + + handleBulkVolumeChapterAction(action: ActionItem, volumes: Volume[], chapters: Chapter[], seriesId: number): Observable> { + switch (action.action) { + case Action.MarkAsRead: + return this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters.map(c => c.id)).pipe( + tap(() => { + volumes.forEach(v => { + v.pagesRead = v.pages; + v.chapters?.forEach(c => c.pagesRead = c.pages); + }); + chapters.forEach(c => c.pagesRead = c.pages); + this.toastr.success(translate('toasts.mark-read')); + }), + map(() => this.fromAction(action, [...volumes, ...chapters], 'update')) + ); + + case Action.MarkAsUnread: + return this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters.map(c => c.id)).pipe( + tap(() => { + volumes.forEach(v => { + v.pagesRead = 0; + v.chapters?.forEach(c => c.pagesRead = 0); + }); + chapters.forEach(c => c.pagesRead = 0); + this.toastr.success(translate('toasts.mark-unread')); + }), + map(() => this.fromAction(action, [...volumes, ...chapters], 'update')) + ); + + case Action.AddToReadingList: { + if (this.readingListModalRef != null) return EMPTY; + const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef>; + this.readingListModalRef = rlRef; + + const volumeIdList = volumes.map(v => v.id); + const chapterIdList = chapters.map(c => c.id); + rlRef.setInput('title', translate('actionable.multiple-selections')); + rlRef.setInput('showCreate', true); + rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label')); + rlRef.setInput('inputItems', []); + rlRef.setInput('loading', true); + + this.readingListService.getReadingLists(false, true).pipe( + take(1), + catchError(() => EMPTY), + finalize(() => rlRef.setInput('loading', false)) + ).subscribe(result => { + rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l }))); + }); + + rlRef.setInput('interceptCreate', (name: string) => + this.readingListService.createList(name).pipe( + switchMap(list => this.readingListService.updateByMultiple(list.id, seriesId, volumeIdList, chapterIdList)), + tap(() => this.toastr.success(translate('toasts.multiple-added-to-reading-list'))) + ) + ); + + rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => { + const list = item as ReadingList; + this.readingListService.updateByMultiple(list.id, seriesId, volumeIdList, chapterIdList).subscribe(() => { + this.toastr.success(translate('toasts.multiple-added-to-reading-list')); + rlRef.close(); + }); + }); + + return new Observable>(subscriber => { + rlRef.closed.subscribe(() => { + this.readingListModalRef = null; + subscriber.next(this.fromAction(action, [...volumes, ...chapters], 'none')); + subscriber.complete(); + }); + rlRef.dismissed.subscribe(() => { + this.readingListModalRef = null; + subscriber.complete(); + }); + }); + } + + case Action.SendTo: { + const device = action._extra!.data as Device; + const chapterIds = [ + ...volumes.flatMap(v => v.chapters?.map(c => c.id) ?? []), + ...chapters.map(c => c.id) + ]; + return this.deviceService.sendToEmailDevice(chapterIds, device.id).pipe( + tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))), + map(() => this.fromAction(action, [...volumes, ...chapters], 'none')) + ); + } + + case Action.Delete: { + const entities = [...volumes, ...chapters]; + const deleteOps: Observable[] = []; + + if (volumes.length > 0) { + deleteOps.push( + from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id))) + ) + ); + } + + if (chapters.length > 0) { + deleteOps.push( + from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapters.length}))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.chapterService.deleteMultipleChapters(seriesId, chapters.map(c => c.id))) + ) + ); + } + + if (deleteOps.length === 0) return EMPTY; + + return from(deleteOps).pipe( + switchMap(op => op), + map(() => this.fromAction(action, entities, 'remove')) + ); + } + + default: + return of(this.fromAction(action, [...volumes, ...chapters], 'none')); + } + } + + handleBulkBookmarkAction(action: ActionItem, bookmarks: PageBookmark[], seriesIds: number[]): Observable> { + switch (action.action) { + case Action.Download: + this.downloadService.download('bookmark', bookmarks); + return of(this.fromAction(action, bookmarks, 'none')); + + case Action.Delete: + return from(this.confirmService.confirm(translate('bookmarks.confirm-single-delete', {seriesName: ''}))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.readerService.clearMultipleBookmarks(seriesIds)), + tap(() => this.toastr.success(translate('bookmarks.delete-single-success'))), + map(() => this.fromAction(action, bookmarks, 'remove')) + ); + + default: + return of(this.fromAction(action, bookmarks, 'none')); + } + } + + handleBulkCollectionAction(action: ActionItem, collections: UserCollection[]): Observable> { + switch (action.action) { + case Action.Promote: + return this.collectionTagService.promoteMultipleCollections(collections.map(c => c.id), true).pipe( + tap(() => this.toastr.success(translate('toasts.collections-promoted'))), + map(() => this.fromAction(action, collections.map(c => ({...c, promoted: true})), 'update')) + ); + + case Action.UnPromote: + return this.collectionTagService.promoteMultipleCollections(collections.map(c => c.id), false).pipe( + tap(() => this.toastr.success(translate('toasts.collections-unpromoted'))), + map(() => this.fromAction(action, collections.map(c => ({...c, promoted: false})), 'update')) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-collections'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.collectionTagService.deleteMultipleCollections(collections.map(c => c.id))), + tap(() => this.toastr.success(translate('toasts.collections-deleted'))), + map(() => this.fromAction(action, collections, 'remove')) + ); + + default: + return of(this.fromAction(action, collections, 'none')); + } + } + + handleBulkReadingListAction(action: ActionItem, readingLists: ReadingList[]): Observable> { + switch (action.action) { + case Action.Promote: + return this.readingListService.promoteMultipleReadingLists(readingLists.map(r => r.id), true).pipe( + tap(() => this.toastr.success(translate('toasts.reading-list-promoted'))), + map(() => this.fromAction(action, readingLists.map(r => ({...r, promoted: true})), 'update')) + ); + + case Action.UnPromote: + return this.readingListService.promoteMultipleReadingLists(readingLists.map(r => r.id), false).pipe( + tap(() => this.toastr.success(translate('toasts.reading-list-unpromoted'))), + map(() => this.fromAction(action, readingLists.map(r => ({...r, promoted: false})), 'update')) + ); + + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.readingListService.deleteMultipleReadingLists(readingLists.map(r => r.id))), + tap(() => this.toastr.success(translate('toasts.reading-lists-deleted'))), + map(() => this.fromAction(action, readingLists, 'remove')) + ); + + default: + return of(this.fromAction(action, readingLists, 'none')); + } + } + + handleBulkAnnotationAction(action: ActionItem, annotations: Annotation[]): Observable> { + switch (action.action) { + case Action.Delete: + return from(this.confirmService.confirm(translate('toasts.confirm-delete-annotations'))).pipe( + filter(confirmed => confirmed), + switchMap(() => this.annotationsService.bulkDelete(annotations.map(a => a.id))), + tap(() => this.toastr.success(translate('toasts.annotations-deleted'))), + map(() => this.fromAction(action, annotations, 'remove')) + ); + + case Action.Export: + return this.annotationsService.exportAnnotations(annotations.map(a => a.id)).pipe( + map(() => this.fromAction(action, annotations, 'none')) + ); + + case Action.Like: + return this.annotationsService.likeAnnotations(annotations.map(a => a.id)).pipe( + map(() => this.fromAction(action, annotations, 'update')) + ); + + case Action.UnLike: + return this.annotationsService.unLikeAnnotations(annotations.map(a => a.id)).pipe( + map(() => this.fromAction(action, annotations, 'update')) + ); + + default: + return of(this.fromAction(action, annotations, 'none')); + } + } + + handleBulkSideNavStreamAction(action: ActionItem, streams: SideNavStream[]): Observable> { + switch (action.action) { + case Action.MarkAsVisible: + return this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), true).pipe( + map(() => this.fromAction(action, streams.map(s => ({...s, visible: true})), 'update')) + ); + + case Action.MarkAsInvisible: + return this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), false).pipe( + map(() => this.fromAction(action, streams.map(s => ({...s, visible: false})), 'update')) + ); + + default: + return of(this.fromAction(action, streams, 'none')); + } + } + + // ------------------------------------------- + // INDIVIDUAL HANDLERS + // ------------------------------------------- /** @@ -82,10 +1288,7 @@ export class ActionService { return; } - // Prompt user if we should do a force or not - const force = false; // await this.promptIfForce(); - - this.libraryService.scan(library.id, force).subscribe((res: any) => { + this.libraryService.scan(library.id, false).subscribe((res: any) => { this.toastr.info(translate('toasts.scan-queued', {name: library.name})); if (callback) { callback(library); @@ -93,156 +1296,6 @@ export class ActionService { }); } - - /** - * Request a refresh of Metadata for a given Library - * @param library Partial Library, must have id and name populated - * @param callback Optional callback to perform actions after API completes - * @param forceUpdate Optional Should we force - * @param forceColorscape Optional Should we force colorscape gen - * @returns - */ - async refreshLibraryMetadata(library: Partial, callback?: LibraryActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) { - if (!library.hasOwnProperty('id') || library.id === undefined) { - return; - } - - // Prompt the user if we are doing a forced call - if (forceUpdate) { - if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { - if (callback) { - callback(library); - } - return; - } - } - - const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; - - this.libraryService.refreshMetadata(library?.id, forceUpdate, forceColorscape).subscribe((res: any) => { - this.toastr.info(translate(message, {name: library.name})); - - if (callback) { - callback(library); - } - }); - } - - editLibrary(library: Partial, callback?: LibraryActionCallback) { - const modalRef = this.modalService.open(LibrarySettingsModalComponent, DefaultModalOptions); - modalRef.componentInstance.library = library; - modalRef.closed.subscribe((closeResult: {success: boolean, library: Library, coverImageUpdate: boolean}) => { - if (callback) callback(library) - }); - } - - async deleteLibrary(library: Partial, callback?: LibraryActionCallback) { - if (!library.hasOwnProperty('id') || library.id === undefined) { - return; - } - - if (!await this.confirmService.alert(translate('toasts.confirm-library-delete'))) { - if (callback) { - callback(library); - } - return; - } - - this.libraryService.delete(library?.id).subscribe(() => { - this.toastr.info(translate('toasts.library-deleted', {name: library.name})); - if (callback) { - callback(library); - } - }); - } - - /** - * Mark a series as read; updates the series pagesRead - * @param series Series, must have id and name populated - * @param callback Optional callback to perform actions after API completes - */ - markSeriesAsRead(series: Series, callback?: SeriesActionCallback) { - this.seriesService.markRead(series.id).subscribe(() => { - series.pagesRead = series.pages; - this.toastr.success(translate('toasts.entity-read', {name: series.name})); - if (callback) { - callback(series); - } - }); - } - - /** - * Mark a series as unread; updates the series pagesRead - * @param series Series, must have id and name populated - * @param callback Optional callback to perform actions after API completes - */ - markSeriesAsUnread(series: Series, callback?: SeriesActionCallback) { - this.seriesService.markUnread(series.id).subscribe(() => { - series.pagesRead = 0; - this.toastr.success(translate('toasts.entity-unread', {name: series.name})); - if (callback) { - callback(series); - } - }); - } - - /** - * Start a file scan for a Series - * @param series Series, must have libraryId and name populated - * @param callback Optional callback to perform actions after API completes - */ - async scanSeries(series: Series, callback?: SeriesActionCallback) { - this.seriesService.scan(series.libraryId, series.id).subscribe(() => { - this.toastr.info(translate('toasts.scan-queued', {name: series.name})); - if (callback) { - callback(series); - } - }); - } - - /** - * Start a file scan for analyze files for a Series - * @param series Series, must have libraryId and name populated - * @param callback Optional callback to perform actions after API completes - */ - analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) { - this.seriesService.analyzeFiles(series.libraryId, series.id).subscribe(() => { - this.toastr.info(translate('toasts.scan-queued', {name: series.name})); - if (callback) { - callback(series); - } - }); - } - - /** - * Start a metadata refresh for a Series - * @param series Series, must have libraryId, id and name populated - * @param callback Optional callback to perform actions after API completes - * @param forceUpdate If cache should be checked or not - * @param forceColorscape If cache should be checked or not - */ - async refreshSeriesMetadata(series: Series, callback?: SeriesActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) { - - // Prompt the user if we are doing a forced call - if (forceUpdate) { - if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { - if (callback) { - callback(series); - } - return; - } - } - - const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; - - this.seriesService.refreshMetadata(series, forceUpdate, forceColorscape).subscribe(() => { - this.toastr.info(translate(message, {name: series.name})); - if (callback) { - callback(series); - } - }); - } - /** * Mark all chapters and the volume as Read * @param seriesId Series Id @@ -261,6 +1314,7 @@ export class ActionService { }); } + /** * Mark all chapters and the volume as unread * @param seriesId Series Id @@ -278,6 +1332,7 @@ export class ActionService { }); } + /** * Mark a chapter as read * @param libraryId Library Id @@ -312,338 +1367,23 @@ export class ActionService { }); } - /** - * Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series - * @param seriesId Series Id - * @param volumes Volumes, should have id, chapters and pagesRead populated - * @param chapters Optional Chapters, should have id - * @param callback Optional callback to perform actions after API completes - */ - markMultipleAsRead(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { - this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).subscribe(() => { - volumes.forEach(volume => { - volume.pagesRead = volume.pages; - volume.chapters?.forEach(c => c.pagesRead = c.pages); - }); - chapters?.forEach(c => c.pagesRead = c.pages); - this.toastr.success(translate('toasts.mark-read')); - - if (callback) { - callback(); - } - }); - } - - /** - * Mark all chapters and the volumes as Unread. All volumes must belong to a series - * @param seriesId Series Id - * @param volumes Volumes, should have id, chapters and pagesRead populated - * @param chapters Optional Chapters, should have id - * @param callback Optional callback to perform actions after API completes - */ - markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { - this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).subscribe(() => { - volumes.forEach(volume => { - volume.pagesRead = 0; - volume.chapters?.forEach(c => c.pagesRead = 0); - }); - chapters?.forEach(c => c.pagesRead = 0); - this.toastr.success(translate('toasts.mark-unread')); - - if (callback) { - callback(); - } - }); - } - - /** - * Mark all series as Read. - * @param series Series, should have id, pagesRead populated - * @param callback Optional callback to perform actions after API completes - */ - markMultipleSeriesAsRead(series: Array, callback?: VoidActionCallback) { - this.readerService.markMultipleSeriesRead(series.map(v => v.id)).subscribe(() => { - series.forEach(s => { - s.pagesRead = s.pages; - }); - this.toastr.success(translate('toasts.mark-read')); - - if (callback) { - callback(); - } - }); - } - - /** - * Mark all series as Unread. - * @param series Series, should have id, pagesRead populated - * @param callback Optional callback to perform actions after API completes - */ - markMultipleSeriesAsUnread(series: Array, callback?: VoidActionCallback) { - this.readerService.markMultipleSeriesUnread(series.map(v => v.id)).subscribe(() => { - series.forEach(s => { - s.pagesRead = s.pages; - }); - this.toastr.success(translate('toasts.mark-unread')); - - if (callback) { - callback(); - } - }); - } - - /** - * Mark all collections as promoted/unpromoted. - * @param collections UserCollection, should have id, pagesRead populated - * @param promoted boolean, promoted state - * @param callback Optional callback to perform actions after API completes - */ - promoteMultipleCollections(collections: Array, promoted: boolean, callback?: BooleanActionCallback) { - this.collectionTagService.promoteMultipleCollections(collections.map(v => v.id), promoted).subscribe(() => { - if (promoted) { - this.toastr.success(translate('toasts.collections-promoted')); - } else { - this.toastr.success(translate('toasts.collections-unpromoted')); - } - - if (callback) { - callback(true); - } - }); - } - - /** - * Deletes multiple collections - * @param collections UserCollection, should have id, pagesRead populated - * @param callback Optional callback to perform actions after API completes - */ - async deleteMultipleCollections(collections: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-collections'))) return; - - this.collectionTagService.deleteMultipleCollections(collections.map(v => v.id)).subscribe(() => { - this.toastr.success(translate('toasts.collections-deleted')); - - if (callback) { - callback(true); - } - }); - } - - /** - * Mark all reading lists as promoted/unpromoted. - * @param readingLists UserCollection, should have id, pagesRead populated - * @param promoted boolean, promoted state - * @param callback Optional callback to perform actions after API completes - */ - promoteMultipleReadingLists(readingLists: Array, promoted: boolean, callback?: BooleanActionCallback) { - this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).subscribe(() => { - if (promoted) { - this.toastr.success(translate('toasts.reading-list-promoted')); - } else { - this.toastr.success(translate('toasts.reading-list-unpromoted')); - } - - if (callback) { - callback(true); - } - }); - } - - async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; - - this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { - if (callback) { - callback(success); - } - }) - } - - async deleteMultipleChapters(seriesId: number, chapterIds: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return; - - this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { - if (callback) { - callback(true); - } - }); - } - - /** - * Deletes multiple collections - * @param readingLists ReadingList, should have id - * @param callback Optional callback to perform actions after API completes - */ - async deleteMultipleReadingLists(readingLists: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; - - this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).subscribe(() => { - this.toastr.success(translate('toasts.reading-lists-deleted')); - - if (callback) { - callback(true); - } - }); - } - - addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: BooleanActionCallback) { - if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesId = seriesId; - this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); - this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); - this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); - this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; - - - this.readingListModalRef.closed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(true); - } - }); - this.readingListModalRef.dismissed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(false); - } - }); - } addMultipleSeriesToWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => { this.toastr.success(translate('toasts.series-added-want-to-read')); - if (callback) { - callback(); - } + callback?.() }); } removeMultipleSeriesFromWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.removeSeriesToWantToRead(seriesIds).subscribe(() => { this.toastr.success(translate('toasts.series-removed-want-to-read')); - if (callback) { - callback(); - } + callback?.() }); } - addMultipleSeriesToReadingList(series: Array, callback?: BooleanActionCallback) { - if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); - this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; - - - this.readingListModalRef.closed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(true); - } - }); - this.readingListModalRef.dismissed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(false); - } - }); - } - - /** - * Adds a set of series to a collection tag - * @param series - * @param callback - * @returns - */ - addMultipleSeriesToCollectionTag(series: Array, callback?: BooleanActionCallback) { - if (this.collectionModalRef != null) { return; } - this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' }); - this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.collectionModalRef.componentInstance.title = translate('actionable.new-collection'); - - this.collectionModalRef.closed.subscribe(() => { - this.collectionModalRef = null; - if (callback) { - callback(true); - } - }); - this.collectionModalRef.dismissed.subscribe(() => { - this.collectionModalRef = null; - if (callback) { - callback(false); - } - }); - } - - addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) { - if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesId = series.id; - this.readingListModalRef.componentInstance.title = series.name; - this.readingListModalRef.componentInstance.type = ADD_FLOW.Series; - - - this.readingListModalRef.closed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(series); - } - }); - this.readingListModalRef.dismissed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(series); - } - }); - } - - addVolumeToReadingList(volume: Volume, seriesId: number, callback?: VolumeActionCallback) { - if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesId = seriesId; - this.readingListModalRef.componentInstance.volumeId = volume.id; - this.readingListModalRef.componentInstance.type = ADD_FLOW.Volume; - - - this.readingListModalRef.closed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(volume); - } - }); - this.readingListModalRef.dismissed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(volume); - } - }); - } - - addChapterToReadingList(chapter: Chapter, seriesId: number, callback?: ChapterActionCallback) { - if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesId = seriesId; - this.readingListModalRef.componentInstance.chapterId = chapter.id; - this.readingListModalRef.componentInstance.type = ADD_FLOW.Chapter; - - - this.readingListModalRef.closed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(chapter); - } - }); - this.readingListModalRef.dismissed.subscribe(() => { - this.readingListModalRef = null; - if (callback) { - callback(chapter); - } - }); - } - editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, DefaultModalOptions); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, editModal()); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { @@ -657,52 +1397,6 @@ export class ActionService { }); } - /** - * Deletes all series - * @param seriesIds - List of series - * @param callback - Optional callback once complete - */ - async deleteMultipleSeries(seriesIds: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: seriesIds.length}))) { - if (callback) { - callback(false); - } - return; - } - this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).subscribe(res => { - if (res) { - this.toastr.success(translate('toasts.series-deleted')); - } else { - this.toastr.error(translate('errors.generic')); - } - - if (callback) { - callback(res); - } - }); - } - - async deleteSeries(series: Series, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-series'))) { - if (callback) { - callback(false); - } - return; - } - - this.seriesService.delete(series.id).subscribe((res: boolean) => { - if (callback) { - if (res) { - this.toastr.success(translate('toasts.series-deleted')); - } else { - this.toastr.error(translate('errors.generic')); - } - - callback(res); - } - }); - } - async deleteChapter(chapterId: number, callback?: BooleanActionCallback) { if (!await this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))) { if (callback) { @@ -748,23 +1442,12 @@ export class ActionService { sendToDevice(chapterIds: Array, device: Device, callback?: VoidActionCallback) { this.deviceService.sendToEmailDevice(chapterIds, device.id).subscribe(() => { this.toastr.success(translate('toasts.file-send-to', {name: device.name})); - if (callback) { - callback(); - } - }); - } - - sendSeriesToDevice(seriesId: number, device: Device, callback?: VoidActionCallback) { - this.deviceService.sendSeriesToEmailDevice(seriesId, device.id).subscribe(() => { - this.toastr.success(translate('toasts.file-send-to', {name: device.name})); - if (callback) { - callback(); - } + callback?.() }); } matchSeries(series: Series, callback?: BooleanActionCallback) { - const ref = this.modalService.open(MatchSeriesModalComponent, DefaultModalOptions); + const ref = this.modalService.open(MatchSeriesModalComponent); ref.componentInstance.series = series; ref.closed.subscribe(saved => { if (callback) { @@ -773,23 +1456,6 @@ export class ActionService { }); } - async deleteFilter(filterId: number, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) { - if (callback) { - callback(false); - } - return; - } - - this.filterService.deleteFilter(filterId).subscribe(_ => { - this.toastr.success(translate('toasts.smart-filter-deleted')); - - if (callback) { - callback(true); - } - }); - } - /** * Sets the reading profile for multiple series * @param series @@ -798,8 +1464,8 @@ export class ActionService { setReadingProfileForMultiple(series: Array, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id) + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal()); + this.readingListModalRef.setInput('seriesIds', series.map(s => s.id)); this.readingListModalRef.closed.subscribe(() => { this.readingListModalRef = null; @@ -823,8 +1489,8 @@ export class ActionService { setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } - this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); - this.readingListModalRef.componentInstance.libraryId = library.id; + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal()); + this.readingListModalRef.setInput('libraryId', library.id); this.readingListModalRef.closed.subscribe(() => { this.readingListModalRef = null; @@ -840,4 +1506,18 @@ export class ActionService { }); } + private handleEditModal(ref: NgbModalRef, action: ActionItem, fallbackEntity: T): Observable> { + return from(ref.closed).pipe( + filter((res: ModalResult) => res.success), + map(res => { + if (res.isDeleted) return this.fromAction(action, fallbackEntity, 'remove'); + return this.fromAction(action, res.data ?? fallbackEntity, 'update'); + }) + ); + } + + + private fromAction(action: ActionItem, data: T, effect: ActionEffect): ActionResult { + return { action: action.action, entity: data, effect: effect }; + } } diff --git a/UI/Web/src/app/_services/annotation.service.ts b/UI/Web/src/app/_services/annotation.service.ts index c9a8be92c..2ff5172c4 100644 --- a/UI/Web/src/app/_services/annotation.service.ts +++ b/UI/Web/src/app/_services/annotation.service.ts @@ -6,7 +6,6 @@ import {TextResonse} from "../_types/text-response"; import {asyncScheduler, map, of, tap} from "rxjs"; import {switchMap, throttleTime} from "rxjs/operators"; import {AccountService} from "./account.service"; -import {User} from "../_models/user/user"; import {MessageHubService} from "./message-hub.service"; import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot"; import {Router} from "@angular/router"; @@ -50,21 +49,15 @@ export class AnnotationService { private _events = signal(null); public readonly events = this._events.asReadonly(); - private readonly user = signal(null); public readonly slots = computed(() => { - const currentUser = this.user(); + const currentUser = this.accountService.currentUser(); return currentUser?.preferences?.bookReaderHighlightSlots ?? []; }); - constructor() { - this.accountService.currentUser$.subscribe(user => { - this.user.set(user!); - }); - } updateSlotColor(index: number, color: RgbaColor) { - const user = this.accountService.currentUserSignal(); + const user = this.accountService.currentUser(); if (!user) return of([]); const preferences = user.preferences; @@ -206,7 +199,7 @@ export class AnnotationService { * @param ids */ likeAnnotations(ids: number[]) { - const userId = this.accountService.currentUserSignal()?.id; + const userId = this.accountService.currentUser()?.id; if (!userId) return of(); return this.httpClient.post(this.baseUrl + 'annotation/like', ids); @@ -217,7 +210,7 @@ export class AnnotationService { * @param ids */ unLikeAnnotations(ids: number[]) { - const userId = this.accountService.currentUserSignal()?.id; + const userId = this.accountService.currentUser()?.id; if (!userId) return of(); return this.httpClient.post(this.baseUrl + 'annotation/unlike', ids); diff --git a/UI/Web/src/app/_services/card-config-factory.service.ts b/UI/Web/src/app/_services/card-config-factory.service.ts new file mode 100644 index 000000000..9b83c4f74 --- /dev/null +++ b/UI/Web/src/app/_services/card-config-factory.service.ts @@ -0,0 +1,431 @@ +import {inject, Injectable, TemplateRef} from "@angular/core"; +import {ImageService} from "./image.service"; +import {ReaderService} from "./reader.service"; +import {ActionableEntity, ActionFactoryService} from "./action-factory.service"; +import {DownloadService} from "../shared/_services/download.service"; +import {Router} from "@angular/router"; +import {RelationshipPipe} from "../_pipes/relationship.pipe"; +import {Series} from "../_models/series"; +import {CardEntity, ChapterCardEntity, RelatedSeriesCardEntity, SeriesCardEntity} from "../_models/card/card-entity"; +import { + ActionableCardConfiguration, + BaseCardConfiguration, + BaseCardConfigurationOverrides, + CardConfigurationOverrides +} from "../_models/card/card-configuration"; +import {Chapter, LooseLeafOrDefaultNumber} from "../_models/chapter"; +import {map} from "rxjs/operators"; +import {Volume} from "../_models/volume"; +import {UserCollection} from "../_models/collection-tag"; +import {ReadingList} from "../_models/reading-list"; +import {LibraryType} from "../_models/library/library"; +import {MangaFormat} from "../_models/manga-format"; +import {User} from "../_models/user/user"; +import {PageBookmark} from "../_models/readers/page-bookmark"; +import {RelatedSeriesPair} from "../_single-module/related-tab/related-tab.component"; +import {ActionItem} from "../_models/actionables/action-item"; +import {SeriesGroup} from "../_models/series-group"; + +export interface ConfigCardFactoryBaseParameters { + shouldRenderAction?: (action: ActionItem, entity: T, user: User) => boolean, + titleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined, + metaTitleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined, + overrides?: BaseCardConfigurationOverrides, +} + +export interface ConfigCardFactoryActionableParameters { + shouldRenderAction?: (action: ActionItem, entity: T, user: User) => boolean, + titleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined, + metaTitleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined, + overrides?: CardConfigurationOverrides, +} + +export interface ConfigCardFactoryChapterVolumeParameters extends ConfigCardFactoryActionableParameters { + seriesId: number; + libraryId: number; + libraryType: number; +} + + +/** + * Factory service that creates CardConfiguration objects for each entity type. + * Provides sensible defaults that can be overridden at call sites. + * + * Usage: + * // In component + * private configFactory = inject(CardConfigFactory); + * + * config = computed(() => this.configFactory.forSeries({ + * allowSelection: true, + * actionables: this.customActions + * })); + */ +@Injectable({ providedIn: 'root' }) +export class CardConfigFactory { + private readonly imageService = inject(ImageService); + private readonly readerService = inject(ReaderService); + private readonly actionFactory = inject(ActionFactoryService); + private readonly downloadService = inject(DownloadService); + private readonly router = inject(Router); + private readonly relationshipPipe = new RelationshipPipe(); + + /** + * Creates configuration for Series cards + */ + forSeries( + params?: ConfigCardFactoryActionableParameters + ): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: false, + selectionType: 'series', + suppressArchiveWarning: false, + + coverFunc: (s) => this.imageService.getSeriesCoverImage(s.id), + titleFunc: (s) => s.name, + titleRouteFunc: (s) => `/library/${s.libraryId}/series/${s.id}`, + metaTitleFunc: (s, wrapper) => { + const seriesWrapper = wrapper as SeriesCardEntity; + if (seriesWrapper.relation) { + return this.relationshipPipe.transform(seriesWrapper.relation); + } + return s.localizedName || s.name; + }, + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + tooltipFunc: (s) => s.name, + progressFunc: (s) => ({ pages: s.pages, pagesRead: s.pagesRead }), + + formatBadgeFunc: (s) => s.format, + countFunc: () => 0, + showErrorFunc: (s) => s.pages === 0, + ariaLabelFunc: (s) => s.name, + + actionableFunc: (s) => this.actionFactory.getSeriesActions(), + readFunc: (s) => this.readerService.readSeries(s, false), + clickFunc: (s) => this.router.navigate(['library', s.libraryId, 'series', s.id]), + + downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe( + map(events => this.downloadService.mapToEntityType(events, s)) + ), + + progressUpdateStrategy: { + getMatchCriteria: (s) => ({ seriesId: s.id }), + // Series cards don't contain chapter/volume details + // Signal that parent needs to refetch + applyUpdate: () => null + } + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + forRelationship( + params?: ConfigCardFactoryBaseParameters + ): BaseCardConfiguration { + const defaults: BaseCardConfiguration = { + allowSelection: false, + selectionType: 'series', + suppressArchiveWarning: false, + + coverFunc: (s) => this.imageService.getSeriesCoverImage(s.series.id), + titleFunc: (s) => s.series.name, + titleRouteFunc: (s) => `/library/${s.series.libraryId}/series/${s.series.id}`, + metaTitleFunc: (s, wrapper) => { + const seriesWrapper = wrapper as RelatedSeriesCardEntity; + if (seriesWrapper.data.relation) { + return this.relationshipPipe.transform(seriesWrapper.data.relation); + } + return s.series.localizedName || s.series.name; + }, + tooltipFunc: (s) => s.series.name, + progressFunc: (s) => ({ pages: s.series.pages, pagesRead: s.series.pagesRead }), + + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: (s) => s.series.format, + countFunc: () => 0, + showErrorFunc: (s) => s.series.pages === 0, + ariaLabelFunc: (s) => s.series.name, + + readFunc: (s) => this.readerService.readSeries(s.series, false), + clickFunc: (s) => this.router.navigate(['library', s.series.libraryId, 'series', s.series.id]), + + downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe( + map(events => this.downloadService.mapToEntityType(events, s.series)) + ) + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + forBookmark( + params?: ConfigCardFactoryActionableParameters + ): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: true, + selectionType: 'bookmark', + suppressArchiveWarning: true, + + coverFunc: (s) => this.imageService.getSeriesCoverImage(s.series!.id), + titleFunc: (s) => s.series!.name, + titleRouteFunc: (s) => `/library/${s.series!.libraryId}/series/${s.seriesId}}`, + metaTitleFunc: (s, wrapper) => s.series!.name, + tooltipFunc: (s) => s.series!.name, + progressFunc: (s) => ({ pages: s.series!.pages, pagesRead: s.series!.pagesRead }), + + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: (s) => s.series!.format, + countFunc: () => 0, + showErrorFunc: (s) => false, + ariaLabelFunc: (s) => s.series!.name, + titleRouteParamsFunc: (s) => {return { bookmarkMode: true }}, + + actionableFunc: (s) => this.actionFactory.getBookmarkActions(() => ({seriesId: s.series!.id, libraryId: s.series!.libraryId, seriesName: s.series!.name}), params?.shouldRenderAction), + + readFunc: (s) => this.router.navigate(['library', s.series!.libraryId, 'series', s.seriesId, 'manga', s.chapterId], {queryParams: {incognitoMode: false, bookmarkMode: true}}), + clickFunc: (s) => this.router.navigate(['library', s.series!.libraryId, 'series', s.seriesId, 'manga', s.chapterId], {queryParams: {incognitoMode: false, bookmarkMode: true}}), + + downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe( + map(events => this.downloadService.mapToEntityType(events, s)) + ) + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + + /** + * Creates configuration for Chapter cards + */ + forChapter(params: ConfigCardFactoryChapterVolumeParameters): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: false, + selectionType: 'chapter', + suppressArchiveWarning: false, + + coverFunc: (c) => this.imageService.getChapterCoverImage(c.id), + titleFunc: (c) => c.titleName || c.title || c.range, + titleRouteFunc: (c) => `/library/${params.libraryId}/series/${params.seriesId}/chapter/${c.id}`, + metaTitleFunc: (c, wrapper) => { + if (c.isSpecial) { + return c.title || c.range; + } + return c.titleName || ''; + }, + tooltipFunc: (c) => c.titleName || c.title || (c.range === (LooseLeafOrDefaultNumber + '') ? '' : c.range), + progressFunc: (c) => ({ pages: c.pages, pagesRead: c.pagesRead }), + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: () => null, + countFunc: (c) => c.files?.length > 1 && c.files[0].format !== MangaFormat.IMAGE ? c.files.length : 0, + showErrorFunc: (c) => { + const wrapper = params?.overrides as unknown as ChapterCardEntity; + return c.pages === 0 && !wrapper?.suppressArchiveWarning; + }, + ariaLabelFunc: (c) => c.titleName || c.title || (c.range === (LooseLeafOrDefaultNumber + '') ? '' : c.range), + + actionableFunc: (c) => this.actionFactory.getChapterActions(params.seriesId, params.libraryId, params.libraryType, params?.shouldRenderAction), + readFunc: (c) => this.readerService.readChapter(params.libraryId, params.seriesId, c, false), + clickFunc: (c) => this.router.navigate(['library', params.libraryId, 'series', params.seriesId, 'chapter', c.id]), + + downloadObservableFunc: (c) => this.downloadService.activeDownloads$.pipe( + map(events => this.downloadService.mapToEntityType(events, c)) + ), + + progressUpdateStrategy: { + getMatchCriteria: (c) => ({ chapterId: c.id }), + applyUpdate: (c, event) => ({ + ...c, + pagesRead: event.pagesRead + }) + } + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + /** + * Creates configuration for Volume cards + */ + forVolume( + params: ConfigCardFactoryChapterVolumeParameters + ): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: true, + selectionType: 'volume', + suppressArchiveWarning: false, + + coverFunc: (v) => this.imageService.getVolumeCoverImage(v.id), + titleFunc: (v) => v.name, + titleRouteFunc: (v) => `/library/${params.libraryId}/series/${params.seriesId}/volume/${v.id}`, + metaTitleFunc: (v) => { + if (params.libraryType === LibraryType.Images) return ''; + if ([LibraryType.LightNovel || LibraryType.Book].includes(params.libraryType)) { + return v.name; + } + if (v.hasOwnProperty('chapters') && v.chapters.length > 0 && v.chapters[0].titleName) { + v.chapters[0].titleName + } + + return v.name; + }, + tooltipFunc: (v) => v.name, + progressFunc: (v) => ({ pages: v.pages, pagesRead: v.pagesRead }), + + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: () => null, + // Show file count if there are duplicate files for volume, not just chapter count + countFunc: (v) => (v?.chapters || []) + .filter(c => c.minNumber === LooseLeafOrDefaultNumber) + .flatMap(c => c.files) + .length, + showErrorFunc: (v) => v.pages === 0, + ariaLabelFunc: (v) => v.name, + + actionableFunc: (v) => this.actionFactory.getVolumeActions(params.seriesId, params.libraryId, params.libraryType, params?.shouldRenderAction), + readFunc: (v) => { + this.readerService.readVolume(params.libraryId, params.seriesId, v, false); + }, + + downloadObservableFunc: (v) => this.downloadService.activeDownloads$.pipe( + map(events => this.downloadService.mapToEntityType(events, v)) + ), + + progressUpdateStrategy: { + getMatchCriteria: (v) => ({volumeId: v.id}), + applyUpdate: (v, event) => { + // Find and update the specific chapter + const chapterIndex = v.chapters.findIndex(c => c.id === event.chapterId); + if (chapterIndex === -1) return v; // Chapter not in this volume + + const updatedChapters = [...v.chapters]; + updatedChapters[chapterIndex] = { + ...updatedChapters[chapterIndex], + pagesRead: event.pagesRead + }; + + return { + ...v, + chapters: updatedChapters, + pagesRead: updatedChapters.reduce((sum, c) => sum + c.pagesRead, 0) + }; + } + } + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + /** + * Creates configuration for Collection cards + */ + forCollection(params?: ConfigCardFactoryActionableParameters): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: true, + selectionType: 'collection', + suppressArchiveWarning: true, + + coverFunc: (c) => this.imageService.getCollectionCoverImage(c.id), + titleFunc: (c) => c.title, + titleRouteFunc: (c) => `/collections/${c.id}`, + metaTitleFunc: (c) => '', + tooltipFunc: (c) => c.title, + progressFunc: () => ({ pages: 0, pagesRead: 0 }), + + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: () => null, + countFunc: (c) => c.itemCount, + showErrorFunc: () => false, + ariaLabelFunc: (c) => c.title, + + actionableFunc: (c) => this.actionFactory.getCollectionTagActions(params?.shouldRenderAction), + readFunc: () => {}, + clickFunc: (c) => this.router.navigate(['collections', c.id]), + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + /** + * Creates configuration for ReadingList cards + */ + forReadingList(params?: ConfigCardFactoryActionableParameters): ActionableCardConfiguration { + const defaults: ActionableCardConfiguration = { + allowSelection: true, + selectionType: 'readingList', + suppressArchiveWarning: true, + + coverFunc: (r) => this.imageService.getReadingListCoverImage(r.id), + titleFunc: (r) => r.title, + titleRouteFunc: (r) => `/lists/${r.id}`, + metaTitleFunc: (r) => r.summary || '', + tooltipFunc: (r) => r.title, + progressFunc: () => ({ pages: 0, pagesRead: 0 }), + + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + + formatBadgeFunc: () => null, + countFunc: (r) => r.itemCount, + showErrorFunc: () => false, + ariaLabelFunc: (r) => r.title, + + actionableFunc: (r) => this.actionFactory.getReadingListActions(params?.shouldRenderAction), + readFunc: () => {}, + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + /** + * Creates configuration for Recently cards + */ + forRecentlyUpdated( + params?: ConfigCardFactoryBaseParameters + ): BaseCardConfiguration { + const defaults: BaseCardConfiguration = { + allowSelection: false, + selectionType: 'series', + suppressArchiveWarning: false, + + coverFunc: (s) => this.imageService.getSeriesCoverImage(s.seriesId), + titleFunc: (s) => s.seriesName, + titleRouteFunc: (s) => `/library/${s.libraryId}/series/${s.seriesId}`, + metaTitleFunc: (s, wrapper) => '', + titleTemplate: params?.titleRef, + metaTitleTemplate: params?.metaTitleRef, + tooltipFunc: (s) => s.seriesName, + progressFunc: (s) => ({ pages: 0, pagesRead: 0 }), + + formatBadgeFunc: (s) => s.format, + countFunc: (s) => s.count, + showErrorFunc: (s) => false, + ariaLabelFunc: (s) => s.seriesName, + + readFunc: (s) => null, + clickFunc: (s) => this.router.navigate(['library', s.libraryId, 'series', s.seriesId]), + }; + + return this.mergeConfig(defaults, params?.overrides); + } + + /** + * Merges default configuration with overrides. + * Overrides take precedence. + */ + private mergeConfig>( + defaults: C, + overrides?: Partial + ): C { + if (!overrides) return defaults; + return { ...defaults, ...overrides } as C; + } +} diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index 919c16c46..060d7dfff 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -4,9 +4,10 @@ import {environment} from 'src/environments/environment'; import {UserCollection} from '../_models/collection-tag'; import {TextResonse} from '../_types/text-response'; import {MalStack} from "../_models/collection/mal-stack"; -import {Action, ActionItem} from "./action-factory.service"; import {User} from "../_models/user/user"; -import {AccountService} from "./account.service"; +import {AccountService, Role} from "./account.service"; +import {ActionItem} from "../_models/actionables/action-item"; +import {Action} from "../_models/actionables/action"; @Injectable({ providedIn: 'root' @@ -18,6 +19,10 @@ export class CollectionTagService { baseUrl = environment.apiUrl; + getCollectionById(collectionId: number) { + return this.httpClient.get(this.baseUrl + 'collection/single?collectionId=' + collectionId); + } + allCollections(ownedOnly = false) { return this.httpClient.get(this.baseUrl + 'collection?ownedOnly=' + ownedOnly); } @@ -27,7 +32,7 @@ export class CollectionTagService { } updateTag(tag: UserCollection) { - return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); + return this.httpClient.post(this.baseUrl + 'collection/update', tag); } promoteMultipleCollections(tags: Array, promoted: boolean) { @@ -59,7 +64,7 @@ export class CollectionTagService { } actionListFilter(action: ActionItem, user: User) { - const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user); + const canPromote = this.accountService.hasRole(user, Role.Admin) || this.accountService.hasRole(user, Role.Promote); const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; if (isPromotionAction) return canPromote; diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 046f3d04f..96b35cc30 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -176,7 +176,7 @@ export class ColorscapeService { * @param complementaryColor */ setColorScape(primaryColor: string, complementaryColor: string | null = null) { - if (this.accountService.currentUserSignal()?.preferences?.colorScapeEnabled === false || this.getCssVariable('--colorscape-enabled') === 'false') { + if (this.accountService.currentUser()?.preferences?.colorScapeEnabled === false || this.getCssVariable('--colorscape-enabled') === 'false') { return; } diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index 445a4e328..61a70b86b 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -1,6 +1,6 @@ import {HttpClient} from '@angular/common/http'; -import {inject, Injectable} from '@angular/core'; -import {ReplaySubject, shareReplay, tap} from 'rxjs'; +import {DestroyRef, inject, Injectable, signal} from '@angular/core'; +import {EMPTY, switchMap, tap} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Device} from '../_models/device/device'; import {DevicePlatform} from '../_models/device/device-platform'; @@ -8,35 +8,41 @@ import {TextResonse} from '../_types/text-response'; import {AccountService} from './account.service'; import {ClientDevice} from "../_models/client-device"; import {map} from "rxjs/operators"; -import {toSignal} from "@angular/core/rxjs-interop"; +import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop"; @Injectable({ providedIn: 'root' }) export class DeviceService { - private httpClient = inject(HttpClient); - private accountService = inject(AccountService); + private readonly httpClient = inject(HttpClient); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); - baseUrl = environment.apiUrl; + readonly baseUrl = environment.apiUrl; - private readonly devicesSource: ReplaySubject = new ReplaySubject(1); - public readonly devices$ = this.devicesSource.asObservable().pipe(shareReplay()); - public readonly devicesSignal = toSignal(this.devices$, { initialValue: [] }); + + + private readonly _devices = signal([]); + public readonly devices = this._devices.asReadonly(); + public readonly devices$ = toObservable(this.devices); constructor() { - // Ensure we are authenticated before we make an authenticated api call. - this.accountService.currentUser$.subscribe(user => { - if (!user) { - this.devicesSource.next([]); - return; - } - this.httpClient.get(this.baseUrl + 'device', {}).subscribe(data => { - this.devicesSource.next(data); - }); + // Ensure we are authenticated before we make an authenticated api call. + toObservable(this.accountService.currentUser).pipe( + switchMap(user => { + if (!user) { + this._devices.set([]); + return EMPTY; + } + return this.httpClient.get(this.baseUrl + 'device'); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(data => { + if (data) this._devices.set([...data]) }); } @@ -54,7 +60,7 @@ export class DeviceService { getEmailDevices() { return this.httpClient.get(this.baseUrl + 'device', {}).pipe(tap(data => { - this.devicesSource.next(data); + this._devices.set([...data]); })); } diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 63aa70a96..e5564c6ea 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,9 +1,8 @@ -import {DestroyRef, inject, Injectable} from '@angular/core'; +import {computed, DestroyRef, inject, Injectable} from '@angular/core'; import {environment} from 'src/environments/environment'; import {ThemeService} from './theme.service'; import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {ImageOnlyName} from "../_models/user/auth-key"; @Injectable({ providedIn: 'root' @@ -14,8 +13,8 @@ export class ImageService { private readonly destroyRef = inject(DestroyRef); baseUrl = environment.apiUrl; - apiKey: string = ''; - encodedKey: string = ''; + apiKey = this.accountService.currentUserImageAuthKey; + encodedKey = computed(() => encodeURIComponent(this.apiKey()!)); public placeholderImage = 'assets/images/image-placeholder.dark-min.png'; public errorImage = 'assets/images/error-placeholder2.dark-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; @@ -37,14 +36,6 @@ export class ImageService { this.noPersonImage = 'assets/images/error-person-missing.min.png'; } }); - - this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { - if (user) { - // Get the image-only key from the auth keys - this.apiKey = user.authKeys.filter(k => k.name === ImageOnlyName)[0].key; - this.encodedKey = encodeURIComponent(this.apiKey); - } - }); } /** @@ -60,51 +51,51 @@ export class ImageService { } getPersonImage(personId: number) { - return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey()}`; } getUserCoverImage(userId: number) { - return `${this.baseUrl}image/user-cover?userId=${userId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/user-cover?userId=${userId}&apiKey=${this.encodedKey()}`; } getLibraryCoverImage(libraryId: number) { - return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey()}`; } getVolumeCoverImage(volumeId: number) { - return `${this.baseUrl}image/volume-cover?volumeId=${volumeId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/volume-cover?volumeId=${volumeId}&apiKey=${this.encodedKey()}`; } getSeriesCoverImage(seriesId: number) { - return `${this.baseUrl}image/series-cover?seriesId=${seriesId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/series-cover?seriesId=${seriesId}&apiKey=${this.encodedKey()}`; } getCollectionCoverImage(collectionTagId: number) { - return `${this.baseUrl}image/collection-cover?collectionTagId=${collectionTagId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/collection-cover?collectionTagId=${collectionTagId}&apiKey=${this.encodedKey()}`; } getReadingListCoverImage(readingListId: number) { - return `${this.baseUrl}image/readinglist-cover?readingListId=${readingListId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/readinglist-cover?readingListId=${readingListId}&apiKey=${this.encodedKey()}`; } getChapterCoverImage(chapterId: number) { - return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey()}`; } getBookmarkedImage(chapterId: number, pageNum: number, imageOffset: number = 0) { - return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}&imageOffset=${imageOffset}`; + return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey()}&pageNum=${pageNum}&imageOffset=${imageOffset}`; } getWebLinkImage(url: string) { - return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey()}`; } getPublisherImage(name: string) { - return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey()}`; } getCoverUploadImage(filename: string) { - return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`; + return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey()}`; } updateErroredWebLinkImage(event: any) { diff --git a/UI/Web/src/app/_services/kavita-title.strategy.ts b/UI/Web/src/app/_services/kavita-title.strategy.ts new file mode 100644 index 000000000..db89e04e3 --- /dev/null +++ b/UI/Web/src/app/_services/kavita-title.strategy.ts @@ -0,0 +1,64 @@ +import {inject, Injectable} from '@angular/core'; +import {Title} from '@angular/platform-browser'; +import {RouterStateSnapshot, TitleStrategy} from '@angular/router'; +import {TranslocoService} from '@jsverse/transloco'; + +@Injectable({providedIn: 'root'}) +export class KavitaTitleStrategy extends TitleStrategy { + private readonly title = inject(Title); + private readonly translocoService = inject(TranslocoService); + + override updateTitle(routerState: RouterStateSnapshot): void { + // 1. Check for static route title (translation key) + const routeTitle = this.buildTitle(routerState); + if (routeTitle) { + this.setFormattedTitle(routeTitle); + return; + } + + // 2. Check for entity-based title from resolved data + const route = this.getDeepestRoute(routerState.root); + const titleField = route.data['titleField']; + if (titleField) { + const titleProp = route.data['titleProp'] || 'name'; + const titleSuffix = route.data['titleSuffix'] || ''; + const entity = this.findInRouteTree(route, titleField); + if (entity?.[titleProp]) { + this.title.setTitle(`Kavita: ${entity[titleProp]}${titleSuffix}`); + return; + } + } + + // 3. Fallback + this.title.setTitle('Kavita'); + } + + setFormattedTitle(pageTitle: string): void { + if (pageTitle.startsWith('title.')) { + pageTitle = this.translocoService.translate(pageTitle); + } + this.title.setTitle(`Kavita: ${pageTitle}`); + } + + setTranslatedTitle(key: string, params: Record): void { + this.title.setTitle(`Kavita: ${this.translocoService.translate(key, params)}`); + } + + private getDeepestRoute(route: RouterStateSnapshot['root']): RouterStateSnapshot['root'] { + while (route.firstChild) { + route = route.firstChild; + } + return route; + } + + private findInRouteTree(route: RouterStateSnapshot['root'], field: string): any { + let current: RouterStateSnapshot['root'] | null = route; + while (current) { + if (current.data[field]) { + return current.data[field]; + } + current = current.parent; + } + return null; + } +} diff --git a/UI/Web/src/app/_services/key-bind.service.ts b/UI/Web/src/app/_services/key-bind.service.ts index 215c50605..13b79c0d8 100644 --- a/UI/Web/src/app/_services/key-bind.service.ts +++ b/UI/Web/src/app/_services/key-bind.service.ts @@ -202,7 +202,7 @@ export class KeyBindService { * @private */ private readonly customKeyBinds = computed(() => { - const customKeyBinds = this.accountService.currentUserSignal()?.preferences.customKeyBinds ?? {}; + const customKeyBinds = this.accountService.currentUser()?.preferences.customKeyBinds ?? {}; return Object.fromEntries(Object.entries(customKeyBinds).filter(([target, _]) => { return DefaultKeyBinds[target as KeyBindTarget] !== undefined; // Filter out unused or old targets })) diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index ed0aebbad..d08a9ac29 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -1,5 +1,5 @@ import {HttpClient} from '@angular/common/http'; -import { DestroyRef, Injectable, inject } from '@angular/core'; +import {DestroyRef, inject, Injectable} from '@angular/core'; import {of} from 'rxjs'; import {filter, map, tap} from 'rxjs/operators'; import {environment} from 'src/environments/environment'; @@ -85,6 +85,10 @@ export class LibraryService { return this.httpClient.get(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId); } + /** + * Admin-only + * @param libraryId + */ getLibrary(libraryId: number) { return this.httpClient.get(this.baseUrl + 'library?libraryId=' + libraryId); } @@ -122,7 +126,7 @@ export class LibraryService { } create(model: {name: string, type: number, folders: string[]}) { - return this.httpClient.post(this.baseUrl + 'library/create', model); + return this.httpClient.post(this.baseUrl + 'library/create', model); } delete(libraryId: number) { @@ -137,7 +141,7 @@ export class LibraryService { } update(model: {name: string, folders: string[], id: number}) { - return this.httpClient.post(this.baseUrl + 'library/update', model); + return this.httpClient.post(this.baseUrl + 'library/update', model); } getLibraryType(libraryId: number) { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 307c504bd..2a91e64c6 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -55,7 +55,7 @@ export class MetadataService { private readonly seriesService = inject(SeriesService) private readonly highlightSlots = computed(() => { - return this.accountService.currentUserSignal()?.preferences?.bookReaderHighlightSlots ?? []; + return this.accountService.currentUser()?.preferences?.bookReaderHighlightSlots ?? []; }); baseUrl = environment.apiUrl; @@ -180,9 +180,9 @@ export class MetadataService { createDefaultFilterStatement(entityType: ValidFilterEntity) { switch (entityType) { case "annotation": - const userId = this.accountService.currentUserSignal()?.id; + const userId = this.accountService.currentUser()?.id; if (userId) { - return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUserSignal()!.id}`); + return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUser()!.id}`); } return this.createFilterStatement(AnnotationsFilterField.Owner); case 'series': diff --git a/UI/Web/src/app/_services/modal.service.ts b/UI/Web/src/app/_services/modal.service.ts index d649a0de5..e7a66d0d1 100644 --- a/UI/Web/src/app/_services/modal.service.ts +++ b/UI/Web/src/app/_services/modal.service.ts @@ -1,6 +1,9 @@ -import {inject, Injectable, Type} from '@angular/core'; +import {ComponentRef, inject, Injectable, Type} from '@angular/core'; import {NgbModal, NgbModalOptions, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +export interface TypedModalRef extends NgbModalRef { + setInput(key: K, value: unknown): void; +} @Injectable({ providedIn: 'root' @@ -9,21 +12,16 @@ export class ModalService { private modal = inject(NgbModal); - open(content: Type, options?: NgbModalOptions): [NgbModalRef, T] { - const modal = this.modal.open(content, options); - return [modal, modal.componentInstance as T] - } + /** * TODO: This is a hack to get the ComponentRef because NgbModalRef does not expose it. + * See https://github.com/ng-bootstrap/ng-bootstrap/issues/4688 */ + open(content: Type, options?: NgbModalOptions): TypedModalRef { + const ref = this.modal.open(content, options) as TypedModalRef; - hasOpenModals() { - return this.modal.hasOpenModals() - } + ref.setInput = (key: string, value: unknown) => { + const componentRef: ComponentRef = (ref as any)['_contentRef'].componentRef; + componentRef.setInput(key, value); + }; - get activeInstances() { - return this.modal.activeInstances + return ref; } - - dismissAll(reason?: any) { - this.modal.dismissAll(reason); - } - } diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 32d813510..95716f3c8 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,6 +1,15 @@ import {DOCUMENT} from '@angular/common'; -import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2, signal} from '@angular/core'; -import {filter, take} from 'rxjs'; +import { + DestroyRef, + effect, + inject, + Injectable, + Renderer2, + RendererFactory2, + RendererStyleFlags2, + signal +} from '@angular/core'; +import {filter} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; @@ -10,7 +19,7 @@ import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop"; import {WikiLink} from "../_models/wiki"; -import {AuthGuard} from "../_guards/auth.guard"; +import {AUTH_URL_KEY} from "../_guards/auth.guard"; /** * NavItem used to construct the dropdown or NavLinkModal on mobile @@ -120,15 +129,16 @@ export class NavService { constructor() { const rendererFactory = inject(RendererFactory2); - this.renderer = rendererFactory.createRenderer(null, null); + // To avoid flashing, let's check if we are authenticated before we show - this.accountService.currentUser$.pipe(take(1)).subscribe(u => { - if (u) { + effect(() => { + const user = this.accountService.currentUser(); + if (user) { this.showNavBar(); } - }); + }) const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false; this.sideNavCollapsed.set(sideNavState); @@ -204,12 +214,12 @@ export class NavService { this.showSideNav(); // Check if user came here from another url, else send to library route - const pageResume = localStorage.getItem(AuthGuard.urlKey); + const pageResume = localStorage.getItem(AUTH_URL_KEY); if (pageResume && pageResume !== '/login') { - localStorage.setItem(AuthGuard.urlKey, ''); + localStorage.setItem(AUTH_URL_KEY, ''); this.router.navigateByUrl(pageResume); } else { - localStorage.setItem(AuthGuard.urlKey, ''); + localStorage.setItem(AUTH_URL_KEY, ''); this.router.navigateByUrl('/home'); } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 4983f6372..668d960d1 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -672,24 +672,22 @@ export class ReaderService { ).catch(err => console.error(err))); } - private handlePrompt(prompt: RereadPrompt, incognitoMode: boolean) { + private handlePrompt(prompt: RereadPrompt, incognitoMode: boolean) { if (incognitoMode) return of({prompt: prompt, result: RereadPromptResult.ReadIncognito}); if (!prompt.shouldPrompt) return of({prompt: prompt, result: RereadPromptResult.Continue}); - const [modal, component] = this.modalService.open(ListSelectModalComponent, { - centered: true, - }); + const ref = this.modalService.open>(ListSelectModalComponent); - component.showFooter.set(false); - component.title.set(translate('reread-modal.title')); + ref.componentInstance.showFooter.set(false); + ref.componentInstance.title.set(translate('reread-modal.title')); if (prompt.timePrompt) { - component.description.set(translate('reread-modal.description-time-passed', + ref.componentInstance.description.set(translate('reread-modal.description-time-passed', { days: prompt.daysSinceLastRead, name: prompt.chapterOnReread.label })); } else { - component.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label })); + ref.componentInstance.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label })); } const options = [ @@ -703,10 +701,10 @@ export class ReaderService { options.push({label: translate('reread-modal.cancel'), value: RereadPromptResult.Cancel}); - component.inputItems.set(options); + ref.setInput('inputItems', options); - return modal.closed.pipe( - takeUntil(modal.dismissed), + return ref.closed.pipe( + takeUntil(ref.dismissed), take(1), map(res => ({prompt: prompt, result: res as RereadPromptResult})), catchError(() => of({prompt: prompt, result: RereadPromptResult.Cancel})) diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 1d778f143..f565b03e5 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -1,5 +1,5 @@ import {HttpClient, HttpParams} from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {map} from 'rxjs/operators'; import {environment} from 'src/environments/environment'; import {UtilityService} from '../shared/_services/utility.service'; @@ -8,7 +8,8 @@ import {PaginatedResult} from '../_models/pagination'; import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; import {TextResonse} from '../_types/text-response'; -import {Action, ActionItem} from './action-factory.service'; +import {ActionItem} from "../_models/actionables/action-item"; +import {Action} from "../_models/actionables/action"; @Injectable({ providedIn: 'root' @@ -98,9 +99,6 @@ export class ReadingListService { if (isPromotionAction) return canPromote; return true; - - // if (readingList?.promoted && !isAdmin) return false; - // return true; } nameExists(name: string) { diff --git a/UI/Web/src/app/_services/recommendation.service.ts b/UI/Web/src/app/_services/recommendation.service.ts index 692d92a08..6aa81238f 100644 --- a/UI/Web/src/app/_services/recommendation.service.ts +++ b/UI/Web/src/app/_services/recommendation.service.ts @@ -1,10 +1,10 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { map } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { PaginatedResult } from '../_models/pagination'; -import { Series } from '../_models/series'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; +import {map, Observable} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {PaginatedResult} from '../_models/pagination'; +import {Series} from '../_models/series'; @Injectable({ providedIn: 'root' @@ -48,6 +48,6 @@ export class RecommendationService { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.get>(this.baseUrl + 'recommended/more-in?libraryId=' + libraryId + '&genreId=' + genreId, {observe: 'response', params}) - .pipe(map(response => this.utilityService.createPaginatedResult(response))); + .pipe(map(response => this.utilityService.createPaginatedResult(response))) as Observable>; } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 9e2015c73..e87245173 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -100,7 +100,7 @@ export class SeriesService { } updateSeries(model: any) { - return this.httpClient.post(this.baseUrl + 'series/update', model); + return this.httpClient.post(this.baseUrl + 'series/update', model); } markRead(seriesId: number) { @@ -143,7 +143,8 @@ export class SeriesService { return this.httpClient.post(url, data, {observe: 'response', params}).pipe( map(response => { return this.utilityService.createPaginatedResult(response, new PaginatedResult()); - })); + }) + ); } isWantToRead(seriesId: number) { @@ -166,7 +167,8 @@ export class SeriesService { return this.httpClient.post(url, data, {observe: 'response', params}).pipe( map(response => { return this.utilityService.createPaginatedResult(response, new PaginatedResult()); - })); + }) + ); } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 9f924d481..a61fda15f 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -96,7 +96,7 @@ export class ThemeService { }); effect(() => { - const user = this.accountService.currentUserSignal(); + const user = this.accountService.currentUser(); if (user?.preferences && user?.preferences.theme) { this.setTheme(user.preferences.theme.name); } else { diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts index 05239269d..a5121d49c 100644 --- a/UI/Web/src/app/_services/version.service.ts +++ b/UI/Web/src/app/_services/version.service.ts @@ -1,40 +1,48 @@ -import {inject, Injectable, OnDestroy} from '@angular/core'; +import {DestroyRef, inject, Injectable} from '@angular/core'; import {interval, Subscription, switchMap} from 'rxjs'; import {ServerService} from "./server.service"; import {AccountService} from "./account.service"; -import {filter, take} from "rxjs/operators"; -import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; -import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; -import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; +import {filter, map, take, tap} from "rxjs/operators"; import {Router} from "@angular/router"; -import {OpdsName} from "../_models/user/auth-key"; +import { + VersionUpdateModalComponent +} from "../announcements/_components/version-update-modal/version-update-modal.component"; +import {versionNotifyModal, versionRefreshModal} from "../_models/modal/modal-options"; +import {UpdateVersionEvent} from "../_models/events/update-version-event"; +import {ModalService} from "./modal.service"; +import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop"; @Injectable({ providedIn: 'root' }) -export class VersionService implements OnDestroy{ +export class VersionService { private readonly serverService = inject(ServerService); private readonly accountService = inject(AccountService); - private readonly modalService = inject(NgbModal); + private readonly modalService = inject(ModalService); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); public static readonly SERVER_VERSION_KEY = 'kavita--version'; public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; - public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown'; - public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown'; + private static readonly DISMISS_KEY_PREFIX = 'kavita--update-dismiss-'; - // Notification intervals - private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once) - private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds - private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds - - // Check intervals private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes - private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours - private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date" + /** Threshold: above this count shows "out of date" instead of "update available" */ + private readonly OUT_OF_BAND_AMOUNT = 3; - // Routes where version update modals should not be shown + /** Backoff intervals indexed by dismiss count: [after 1st dismiss, after 2nd dismiss] */ + private readonly BACKOFF_INTERVALS = [ + 1 * 24 * 60 * 60 * 1000, // 1 day + 3 * 24 * 60 * 60 * 1000, // 3 days + 7 * 24 * 60 * 60 * 1000, // 1 week + 14 * 24 * 60 * 60 * 1000, // 2 weeks + 30 * 24 * 60 * 60 * 1000, // 1 month + ]; + /** After this many dismissals, Kavita will stop pestering the user */ + private readonly MAX_DISMISSALS = 5; + + /** Routes where version update modals should not be shown */ private readonly EXCLUDED_ROUTES = [ '/manga/', '/book/', @@ -44,208 +52,227 @@ export class VersionService implements OnDestroy{ private versionCheckSubscription?: Subscription; - private outOfDateCheckSubscription?: Subscription; private modalOpen = false; + /** Version fetched on initial page load — used to detect mid-session server updates */ + private loadedVersion: string | null = null; + /** Tracks which version the currently-open modal is for, so we can record dismissal on close */ + private activeModalVersion: string | null = null; constructor() { this.startInitialVersionCheck(); this.startVersionCheck(); - this.startOutOfDateCheck(); - } - - ngOnDestroy() { - this.versionCheckSubscription?.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!.authKeys.filter(k => k.name === OpdsName)[0].key)) - ) - .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); - }); + toObservable(this.accountService.currentUserGenericApiKey).pipe( + filter((key): key is string => !!key), + take(1), + switchMap(key => this.serverService.getVersion(key)) + ).subscribe(serverVersion => { + this.loadedVersion = serverVersion; + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + this.cleanupOldDismissals(serverVersion); + console.log('Initial version check - Server version:', serverVersion); + }); } + /** * Periodic check for server version to detect client refreshes and new updates */ private startVersionCheck(): void { - console.log('Starting version checker'); this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL) .pipe( - switchMap(() => this.accountService.currentUser$), - filter(user => !!user && !this.modalOpen), - switchMap(user => this.serverService.getVersion(user!.authKeys.filter(k => k.name === OpdsName)[0].key)), + map(() => this.accountService.currentUserGenericApiKey()), + filter((key): key is string => !!key && !this.modalOpen), + switchMap(key => this.serverService.getVersion(key)), filter(update => !!update), - ).subscribe(version => this.handleVersionUpdate(version)); - } - - /** - * Checks if the server is out of date compared to the latest release - */ - private startOutOfDateCheck() { - console.log('Starting out-of-date checker'); - this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL) - .pipe( - switchMap(() => this.accountService.currentUser$), - filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen), - switchMap(_ => this.serverService.checkHowOutOfDate(true)), - filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT), - ) - .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); + tap(serverVersion => this.handleVersionCheck(serverVersion)), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); } /** * Checks if the current route is in the excluded routes list */ - private isExcludedRoute(): boolean { + 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 + * Given a server version string, determines whether to show a refresh modal + * (server updated mid-session) or check for available updates. */ - private handleVersionUpdate(serverVersion: string) { - if (this.modalOpen) return; + handleVersionCheck(serverVersion: string): void { + if (this.modalOpen || this.isExcludedRoute()) 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 isNewServerVersion = this.loadedVersion !== null && this.loadedVersion !== serverVersion; - const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); - console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); - - const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion; - - // Case 1: Client Refresh needed (server has updated since last client load) if (isNewServerVersion) { - this.showClientRefreshNotification(serverVersion); + // Server was updated mid-session — don't update loadedVersion so the + // refresh prompt persists until the user actually refreshes. + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + this.serverService.getChangelog(1).subscribe(changelog => { + this.showRefreshModal(changelog[0]); + localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); + }); + } else { + this.handleUpdateCheck(); } - // Case 2: Check for new updates (for server admin) - else { - this.checkForNewUpdates(); - } - - // Always update the cached version - localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); } /** - * Shows a notification that client refresh is needed due to server update + * Checks if the admin should be notified of a new update or that the server is significantly out of date. + * Single API call to checkHowOutOfDate determines which modal (if any) to show. */ - private showClientRefreshNotification(newVersion: string): void { - this.pauseChecks(); - - // Client refresh notifications should always show (once) - this.modalOpen = true; - - this.serverService.getChangelog(1).subscribe(changelog => { - const ref = this.modalService.open(NewUpdateModalComponent, { - size: 'lg', - keyboard: false, - backdrop: 'static' // Prevent closing by clicking outside - }); - - ref.componentInstance.version = newVersion; - ref.componentInstance.update = changelog[0]; - ref.componentInstance.requiresRefresh = true; - - // Update the last shown timestamp - localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); - - ref.closed.subscribe(_ => this.onModalClosed()); - ref.dismissed.subscribe(_ => this.onModalClosed()); + handleUpdateCheck(): void { + if (!this.accountService.hasAdminRole()) return; + this.serverService.checkHowOutOfDate().pipe( + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0), + ).subscribe(versionsOutOfDate => { + if (versionsOutOfDate > this.OUT_OF_BAND_AMOUNT) { + this.handleOutOfDate(versionsOutOfDate); + } else { + this.handleUpdateAvailable(versionsOutOfDate); + } }); } /** - * Checks for new server updates and shows notification if appropriate + * Given a versionsOutOfDate count (1–3), fetches changelog and shows the + * update-available modal. Backoff is applied in showUpdateModal. */ - private checkForNewUpdates(): void { - this.accountService.currentUser$ - .pipe( - take(1), - filter(user => user !== undefined && this.accountService.hasAdminRole(user)), - switchMap(_ => this.serverService.checkHowOutOfDate()), - filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT) - ) - .subscribe(versionsOutOfDate => { - const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0'); - const currentTime = Date.now(); - - // Show notification if it hasn't been shown in the last week - if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) { - this.pauseChecks(); - this.modalOpen = true; - - this.serverService.getChangelog(1).subscribe(changelog => { - const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' }); - ref.componentInstance.versionsOutOfDate = versionsOutOfDate; - ref.componentInstance.update = changelog[0]; - ref.componentInstance.requiresRefresh = false; - - // Update the last shown timestamp - localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString()); - - ref.closed.subscribe(_ => this.onModalClosed()); - ref.dismissed.subscribe(_ => this.onModalClosed()); - }); - } - }); + handleUpdateAvailable(versionsOutOfDate: number): void { + this.serverService.getChangelog(1).subscribe(changelog => { + this.showUpdateAvailableModal(changelog[0], versionsOutOfDate); + }); } /** - * Handles the notification for servers that are significantly out of date + * Given a versionsOutOfDate count (4+), shows the out-of-date modal. + * Backoff is applied in showUpdateModal. */ - private handleOutOfDateNotification(versionsOutOfDate: number): void { - const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0'); - const currentTime = Date.now(); + handleOutOfDate(versionsOutOfDate: number): void { + this.showOutOfDateModal(versionsOutOfDate); + } - // Show notification if it hasn't been shown in the last month - if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) { - this.pauseChecks(); - this.modalOpen = true; + /** + * Single entry point for opening version update modals. + * Prevents stacking — only one modal can be open at a time. + * For non-refresh modes, applies per-version backoff before opening. + */ + showUpdateModal(mode: 'refresh' | 'update-available' | 'out-of-date', data: { update?: UpdateVersionEvent | null, versionsOutOfDate?: number } = {}, force: boolean = false): void { + if (this.modalOpen) return; - const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' }); - ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + // Per-version backoff for dismissible modes (skipped for refresh and user-initiated actions) + if (mode !== 'refresh' && !force) { + const backoffVersion = this.getBackoffVersion(mode, data); + if (backoffVersion && !this.shouldShowNotification(backoffVersion)) return; + this.activeModalVersion = backoffVersion; + } - // Update the last shown timestamp - localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString()); + this.pauseChecks(); + this.modalOpen = true; - ref.closed.subscribe(_ => this.onModalClosed()); - ref.dismissed.subscribe(_ => this.onModalClosed()); + const options = mode === 'refresh' ? versionRefreshModal() : versionNotifyModal(); + const ref = this.modalService.open(VersionUpdateModalComponent, options); + ref.setInput('mode', mode); + + if (data?.update != null) ref.setInput('update', data.update); + if (data?.versionsOutOfDate != null) ref.setInput('versionsOutOfDate', data.versionsOutOfDate); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + } + + /** + * Shows the refresh-required modal. The server was updated mid-session + * and the browser needs to reload to pick up new client assets. + */ + showRefreshModal(update: UpdateVersionEvent): void { + this.showUpdateModal('refresh', { update }); + } + + /** + * Shows the update-available modal. A newer version exists that the admin can download. + */ + showUpdateAvailableModal(update: UpdateVersionEvent, versionsOutOfDate: number = 1): void { + this.showUpdateModal('update-available', { update, versionsOutOfDate }); + } + + /** + * Shows the out-of-date warning modal. The server is significantly behind the latest release. + */ + showOutOfDateModal(versionsOutOfDate: number): void { + this.showUpdateModal('out-of-date', { versionsOutOfDate }); + } + + /** + * Determines the version string used for backoff tracking. + * update-available: the version available to update to. + * out-of-date: the current server version (resets when user updates). + */ + private getBackoffVersion(mode: string, data: { update?: UpdateVersionEvent | null }): string | null { + if (mode === 'update-available') return data.update?.updateVersion ?? null; + if (mode === 'out-of-date') return this.loadedVersion; + return null; + } + + /** + * Checks per-version dismissal history to determine if we should show the notification. + * Returns false if the user has dismissed enough times or too recently. + */ + shouldShowNotification(targetVersion: string): boolean { + const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion); + if (!raw) return true; + + const { count, lastDismissed } = JSON.parse(raw) as { count: number; lastDismissed: number }; + if (count >= this.MAX_DISMISSALS) return false; + + const interval = this.BACKOFF_INTERVALS[Math.min(count - 1, this.BACKOFF_INTERVALS.length - 1)]; + return Date.now() - lastDismissed >= interval; + } + + /** + * Records a dismissal for the given version, incrementing the count and updating the timestamp. + */ + recordDismissal(targetVersion: string): void { + const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion); + const current = raw ? JSON.parse(raw) as { count: number } : { count: 0 }; + localStorage.setItem(VersionService.DISMISS_KEY_PREFIX + targetVersion, JSON.stringify({ + count: current.count + 1, + lastDismissed: Date.now(), + })); + } + + /** + * Removes dismiss keys for versions other than the current server version. + * Prevents stale backoff data from carrying over after an update. + */ + private cleanupOldDismissals(currentVersion: string): void { + const keepKey = VersionService.DISMISS_KEY_PREFIX + currentVersion; + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith(VersionService.DISMISS_KEY_PREFIX) && key !== keepKey) { + localStorage.removeItem(key); + } } } - /** - * Pauses all version checks while modals are open - */ private pauseChecks(): void { this.versionCheckSubscription?.unsubscribe(); - this.outOfDateCheckSubscription?.unsubscribe(); } - /** - * Resumes all checks when modals are closed - */ private onModalClosed(): void { + if (this.activeModalVersion) { + this.recordDismissal(this.activeModalVersion); + this.activeModalVersion = null; + } this.modalOpen = false; this.startVersionCheck(); - this.startOutOfDateCheck(); } } diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html deleted file mode 100644 index e7ffdfe28..000000000 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts deleted file mode 100644 index 56d83ec6f..000000000 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - EventEmitter, - inject, - Input, - OnInit, - Output -} from '@angular/core'; -import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {UtilityService} from "../../shared/_services/utility.service"; -import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; -import {Action, ActionableEntity, ActionItem} from "../../_services/action-factory.service"; -import {AccountService} from "../../_services/account.service"; -import {tap} from "rxjs"; -import {User} from "../../_models/user/user"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; - -@Component({ - selector: 'app-actionable-modal', - imports: [ - TranslocoDirective - ], - templateUrl: './actionable-modal.component.html', - styleUrl: './actionable-modal.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ActionableModalComponent implements OnInit { - - protected readonly utilityService = inject(UtilityService); - protected readonly modal = inject(NgbActiveModal); - protected readonly accountService = inject(AccountService); - protected readonly cdRef = inject(ChangeDetectorRef); - protected readonly destroyRef = inject(DestroyRef); - - @Input() entity: ActionableEntity = null; - @Input() actions: ActionItem[] = []; - @Input() willRenderAction!: (action: ActionItem, user: User) => boolean; - @Input() shouldRenderSubMenu!: (action: ActionItem, dynamicList: null | Array) => boolean; - @Output() actionPerformed = new EventEmitter>(); - - currentLevel: string[] = []; - currentItems: ActionItem[] = []; - user!: User | undefined; - - ngOnInit() { - // Copy as the list may be shared between entities - const actionItems = this.actions.map(action => this.utilityService.copyActionItem(action)); - - // On Mobile, surface download - const otherActionIndex = actionItems.findIndex(i => i.action === Action.Submenu && i.title === 'others') - if (otherActionIndex >= 0) { - const downloadActionIndex = actionItems[otherActionIndex].children.findIndex(a => a.action === Action.Download); - - if (downloadActionIndex >= 0) { - const downloadAction = actionItems[otherActionIndex].children.splice(downloadActionIndex, 1)[0]; - actionItems.push(downloadAction); - - // Check if Other has any other children, else remove - if (actionItems[otherActionIndex].children.length === 0) { - actionItems.splice(otherActionIndex, 1); - } - } - } - - this.actions = actionItems; - this.currentItems = this.translateOptions(this.actions) - - this.accountService.currentUser$.pipe(tap(user => { - this.user = user; - this.cdRef.markForCheck(); - }), takeUntilDestroyed(this.destroyRef)).subscribe(); - } - - handleItemClick(item: ActionItem) { - if (item.children && item.children.length > 0) { - this.currentLevel.push(item.title); - - if (item.children.length === 1 && item.children[0].dynamicList) { - item.children[0].dynamicList.subscribe(dynamicItems => { - this.currentItems = dynamicItems.map(di => ({ - ...item, - children: [], // Required as dynamic list is only one deep - title: di.title, - _extra: di, - action: item.children[0].action // override action to be correct from child - })); - }); - } else { - this.currentItems = this.translateOptions(item.children); - } - } - else { - this.actionPerformed.emit(item); - this.modal.close(item); - } - this.cdRef.markForCheck(); - } - - handleBack() { - if (this.currentLevel.length > 0) { - this.currentLevel.pop(); - - let items = this.actions; - for (let level of this.currentLevel) { - items = items.find(item => item.title === level)?.children || []; - } - - this.currentItems = this.translateOptions(items); - this.cdRef.markForCheck(); - } - } - - translateOptions(opts: Array>) { - return opts.map(a => { - return {...a, title: translate('actionable.' + a.title)}; - }) - } - -} diff --git a/UI/Web/src/app/_single-module/activity-card/activity-card.component.html b/UI/Web/src/app/_single-module/activity-card/activity-card.component.html index 22de2af16..a223f76a5 100644 --- a/UI/Web/src/app/_single-module/activity-card/activity-card.component.html +++ b/UI/Web/src/app/_single-module/activity-card/activity-card.component.html @@ -47,7 +47,7 @@
-
+
diff --git a/UI/Web/src/app/_single-module/activity-card/activity-card.component.scss b/UI/Web/src/app/_single-module/activity-card/activity-card.component.scss index f5966c759..65c532281 100644 --- a/UI/Web/src/app/_single-module/activity-card/activity-card.component.scss +++ b/UI/Web/src/app/_single-module/activity-card/activity-card.component.scss @@ -3,14 +3,14 @@ .activity-card { background: var(--elevation-layer1-dark-solid); - border-radius: 8px; + border-radius: 0.5rem; overflow: hidden; - margin-bottom: 20px; + margin-bottom: 1.25rem; transition: transform 0.2s ease; &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--elevation-layer10-dark); + transform: translateY(-0.125rem); + box-shadow: 0 0.25rem 0.75rem var(--elevation-layer10-dark); } .activity-content { @@ -22,8 +22,8 @@ .cover-container { position: relative; flex-shrink: 0; - width: 200px; - height: 300px; + width: 12.5rem; + height: 18.75rem; .series-cover { width: 100%; @@ -33,20 +33,20 @@ .chapter-cover { position: absolute; - bottom: 8px; - right: 8px; - width: 60px; - height: 85px; + bottom: 0.5rem; + right: 0.5rem; + width: 3.75rem; + height: 5.3125rem; object-fit: cover; - border: 2px solid var(--elevation-layer1-dark-solid); - border-radius: 4px; - box-shadow: 0 2px 8px var(--elevation-layer11-dark); + border: 0.125rem solid var(--elevation-layer1-dark-solid); + border-radius: 0.25rem; + box-shadow: 0 0.125rem 0.5rem var(--elevation-layer11-dark); } } .activity-details { flex: 1; - padding: 16px; + padding: 1rem; display: flex; flex-direction: column; background: linear-gradient(to right, var(--elevation-layer11-dark), var(--elevation-layer1-dark-solid)); @@ -57,76 +57,76 @@ display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: 12px; + margin-bottom: 0.75rem; flex-wrap: wrap; - gap: 8px; + gap: 0.5rem; } .media-info { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 0.375rem; align-items: center; } .platform-badge { background: var(--activity-card-client-platform-badge-bg-color) !important; - font-size: 11px; - padding: 4px 8px; + font-size: 0.6875rem; + padding: 0.25rem 0.5rem; font-weight: 600; } .device-badge { background: var(--activity-card-client-device-badge-bg-color); - font-size: 11px; - padding: 4px 8px; + font-size: 0.6875rem; + padding: 0.25rem 0.5rem; } .session-metadata { - margin-bottom: 12px; + margin-bottom: 0.75rem; .metadata-row { display: flex; - gap: 8px; - margin-bottom: 4px; - font-size: 12px; + gap: 0.5rem; + margin-bottom: 0.25rem; + font-size: 0.75rem; .label { color: var(--offwhite-text-color); text-transform: uppercase; font-weight: 600; - min-width: 80px; + min-width: 5rem; } } } .progress-container { - margin-bottom: 12px; + margin-bottom: 0.75rem; .progress { background: var(--progress-bg-color); - border-radius: 2px; + border-radius: 0.125rem; } .progress-time { display: flex; justify-content: flex-end; - margin-top: 4px; - font-size: 11px; + margin-top: 0.25rem; + font-size: 0.6875rem; color: var(--offwhite-text-color); } } .title-section { display: flex; - gap: 12px; + gap: 0.75rem; align-items: center; - margin-bottom: 12px; + margin-bottom: 0.75rem; margin-top: auto; .play-icon { - font-size: 24px; + font-size: 1.5rem; color: var(--primary-color); flex-shrink: 0; } @@ -138,7 +138,7 @@ .series-title { color: var(--primary-color); - font-size: 16px; + font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; @@ -147,7 +147,7 @@ .chapter-info { color: var(--offwhite-text-color); - font-size: 13px; + font-size: 0.8125rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -158,12 +158,12 @@ display: flex; justify-content: flex-end; align-items: center; - padding-top: 8px; + padding-top: 0.5rem; border-top: 1px solid var(--offwhite-text-color); .username { color: var(--offwhite-text-color); - font-size: 13px; + font-size: 0.8125rem; font-weight: 500; } } @@ -173,21 +173,21 @@ @media (max-width: 768px) { .activity-card { .cover-container { - width: 120px; - height: 180px; + width: 7.5rem; + height: 11.25rem; .chapter-cover { - width: 45px; - height: 65px; + width: 2.8125rem; + height: 4.0625rem; } } .activity-details { - padding: 12px; + padding: 0.75rem; } .series-title { - font-size: 14px; + font-size: 0.875rem; } } } diff --git a/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html index 03982f436..a61c93964 100644 --- a/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html +++ b/UI/Web/src/app/_single-module/annotations-tab/annotations-tab.component.html @@ -2,7 +2,7 @@
@for(item of scroll.viewPortItems; let idx = $index; track item.id) { -
+
+ + + diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.scss b/UI/Web/src/app/_single-module/card-actionables/_modals/actionable-modal/actionable-modal.component.scss similarity index 100% rename from UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.scss rename to UI/Web/src/app/_single-module/card-actionables/_modals/actionable-modal/actionable-modal.component.scss diff --git a/UI/Web/src/app/_single-module/card-actionables/_modals/actionable-modal/actionable-modal.component.ts b/UI/Web/src/app/_single-module/card-actionables/_modals/actionable-modal/actionable-modal.component.ts new file mode 100644 index 000000000..b905a80fe --- /dev/null +++ b/UI/Web/src/app/_single-module/card-actionables/_modals/actionable-modal/actionable-modal.component.ts @@ -0,0 +1,129 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + inject, + input, + OnInit, + signal, + output +} from '@angular/core'; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {UtilityService} from "../../../../shared/_services/utility.service"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {AccountService} from "../../../../_services/account.service"; +import {Observable} from "rxjs"; +import {ActionableEntity} from "../../../../_services/action-factory.service"; +import {ActionItem} from "../../../../_models/actionables/action-item"; +import {Action} from "../../../../_models/actionables/action"; +import {ActionResult} from "../../../../_models/actionables/action-result"; + +@Component({ + selector: 'app-actionable-modal', + imports: [ + TranslocoDirective + ], + templateUrl: './actionable-modal.component.html', + styleUrl: './actionable-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ActionableModalComponent implements OnInit { + + protected readonly utilityService = inject(UtilityService); + protected readonly modal = inject(NgbActiveModal); + protected readonly accountService = inject(AccountService); + protected readonly cdRef = inject(ChangeDetectorRef); + protected readonly destroyRef = inject(DestroyRef); + + + entity = input(null); + /** This assumes these are filtered actions */ + filteredActions = input[]>([]); + readonly actionPerformed = output | ActionResult>(); + + currentItems = signal[]>([]); + currentLevel = signal([]); + + ngOnInit() { + // Copy as the list may be shared between entities + const actionItems = this.surfaceDownloadAction( + this.filteredActions().map(a => this.utilityService.copyActionItem(a)) + ); + this.currentItems.set(this.translateOptions(actionItems)); + } + + handleItemClick(item: ActionItem) { + if (item.children?.length > 0) { + this.currentLevel.update(levels => [...levels, item.title]); + + if (item.children.length === 1 && item.children[0].dynamicList) { + item.children[0].dynamicList.subscribe(dynamicItems => { + this.currentItems.set(dynamicItems.map(di => ({ + ...item, + children: [], // Required as dynamic list is only one deep + title: di.title, + _extra: di, + action: item.children[0].action // override action to be correct from child + }))); + }); + } else { + this.currentItems.set(this.translateOptions(item.children)); + } + return; + } + + const result = item.callback(item, this.entity()); + + if (result && typeof (result as any).subscribe === 'function') { + (result as Observable>).subscribe(actionResult => { + this.actionPerformed.emit(actionResult); + this.modal.close(actionResult); + }); + return; + } + this.modal.close(item); + } + + handleBack() { + this.currentLevel.update(levels => { + const next = levels.slice(0, -1); + + let items = this.filteredActions().map(a => this.utilityService.copyActionItem(a)); + items = this.surfaceDownloadAction(items); + + for (const level of next) { + items = items.find(i => i.title === level)?.children || []; + } + + this.currentItems.set(this.translateOptions(items)); + return next; + }); + } + + translateOptions(opts: Array>) { + return opts.map(a => { + return {...a, title: translate('actionable.' + a.title)}; + }) + } + + /** + * On mobile, pull the Download action out of the "Others" submenu to top-level. + */ + private surfaceDownloadAction(items: ActionItem[]): ActionItem[] { + const otherIdx = items.findIndex(i => i.action === Action.Submenu && i.title === 'others'); + if (otherIdx < 0) return items; + + const dlIdx = items[otherIdx].children.findIndex(a => a.action === Action.Download); + if (dlIdx < 0) return items; + + const downloadAction = items[otherIdx].children.splice(dlIdx, 1)[0]; + items.push(downloadAction); + + if (items[otherIdx].children.length === 0) { + items.splice(otherIdx, 1); + } + + return items; + } +} diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index 386ce9414..1db8558d1 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -1,6 +1,6 @@ @let labelId = 'actions-' + labelBy(); - @if (actions().length > 0) { + @if (filteredActions().length > 0) { @if (breakpointService.isTabletOrBelow()) {
- +
@@ -26,11 +26,11 @@ @for(dynamicItem of dList; track dynamicItem.title) { } - } @else if (willRenderAction(action, this.currentUser()!)) { + } @else { } } @else { - @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser()!)) { + @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
('fa-ellipsis-v'); @@ -44,9 +56,18 @@ export class CardActionablesComponent implements OnDestroy { /** * This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked. */ - @Output() actionHandler = new EventEmitter>(); + readonly actionHandler = output>(); - currentUser = this.accountService.currentUserSignal; + filteredActions = computed(() => { + const entity = this.entity(); + const user = this.accountService.currentUser(); + const actions = this.actions(); + if (!user || !actions.length) return []; + + return filterActionTree(actions, entity, user, this.accountService); + }); + + currentUser = this.accountService.currentUser; submenu: {[key: string]: NgbDropdown} = {}; private closeTimeout: any = null; @@ -62,13 +83,9 @@ export class CardActionablesComponent implements OnDestroy { performAction(event: any, action: ActionItem) { this.preventEvent(event); - if (typeof action.callback === 'function') { - if (this.entity() === null) { - this.actionHandler.emit(action); - } else { - action.callback(action, this.entity()); - } - } + action.callback(action, this.entity()).subscribe(actionResult => { + this.actionHandler.emit(actionResult); + }); } /** @@ -77,7 +94,11 @@ export class CardActionablesComponent implements OnDestroy { * @param user */ willRenderAction(action: ActionItem, user: User) { - return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity(), user); + const hasValidRole = !action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles); + const shouldRenderFuncPasses = action.shouldRender(action, this.entity(), user); + //console.log('Action: ', action, 'has valid role: ', hasValidRole, ' and should render func passes: ', shouldRenderFuncPasses); + + return hasValidRole && shouldRenderFuncPasses; } shouldRenderSubMenu(action: ActionItem, dynamicList: null | Array) { @@ -119,19 +140,6 @@ export class CardActionablesComponent implements OnDestroy { } } - hasRenderableChildren(action: ActionItem, user: User): boolean { - if (!action.children || action.children.length === 0) return false; - - for (const child of action.children) { - const dynamicList = child.dynamicList; - if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded - - if (this.willRenderAction(child, user)) return true; - if (child.children?.length && this.hasRenderableChildren(child, user)) return true; - } - return false; - } - performDynamicClick(event: any, action: ActionItem, dynamicItem: any) { action._extra = dynamicItem; this.performAction(event, action); @@ -140,13 +148,13 @@ export class CardActionablesComponent implements OnDestroy { openMobileActionableMenu(event: any) { this.preventEvent(event); - const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true}); - ref.componentInstance.entity = this.entity(); - ref.componentInstance.actions = this.actions(); - ref.componentInstance.willRenderAction = this.willRenderAction.bind(this); - ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this); - ref.componentInstance.actionPerformed.subscribe((action: ActionItem) => { - this.performAction(event, action); + // TODO: See if we can use a drawer instead + const ref = this.modalService.open(ActionableModalComponent); + ref.setInput('entity', this.entity()); + ref.setInput('filteredActions', this.filteredActions()); + + ref.componentInstance.actionPerformed.subscribe((actionOrResult: any) => { + this.actionHandler.emit(actionOrResult); }); } } diff --git a/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.html b/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.html index 780b4d1b3..4c91a0ccc 100644 --- a/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.html +++ b/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.html @@ -30,7 +30,7 @@
- +
diff --git a/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.ts b/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.ts index 25cbc5670..94b2c42b8 100644 --- a/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.ts +++ b/UI/Web/src/app/_single-module/client-device-card/client-device-card.component.ts @@ -12,7 +12,6 @@ import {UtcToLocalDatePipe} from "../../_pipes/utc-to-locale-date.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {CardActionablesComponent} from "../card-actionables/card-actionables.component"; -import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; import {DeviceService} from "../../_services/device.service"; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; @@ -20,6 +19,9 @@ import {DOCUMENT} from "@angular/common"; import {AccountService} from "../../_services/account.service"; import {User} from "../../_models/user/user"; import {Breakpoint, BreakpointService} from "../../_services/breakpoint.service"; +import {ActionFactoryService} from "../../_services/action-factory.service"; +import {ActionItem} from "../../_models/actionables/action-item"; +import {ActionResult} from "../../_models/actionables/action-result"; @Component({ selector: 'app-client-device-card', @@ -69,7 +71,7 @@ export class ClientDeviceCardComponent { }); currentUserId = computed(() => { - return this.accountService.currentUserSignal()?.id; + return this.accountService.currentUser()?.id; }); ipAddress = computed(() => { @@ -154,25 +156,23 @@ export class ClientDeviceCardComponent { constructor() { - const user = this.accountService.currentUserSignal(); - if (user && !this.accountService.hasReadOnlyRole(user)) { - this.actions.set(this.actionFactoryService.getClientDeviceActions(this.handleActionCallback.bind(this), this.shouldRenderAction.bind(this))); + if (!this.accountService.hasReadOnlyRole()) { + this.actions.set(this.actionFactoryService.getClientDeviceActions(this.shouldRenderAction.bind(this))); } } shouldRenderAction(action: ActionItem, entity: ClientDevice, user: User) { - const loggedInUser = this.accountService.currentUserSignal(); + const loggedInUser = this.accountService.currentUser(); return entity.ownerUserId === loggedInUser?.id; // Only a user can manipulate their own devices } - handleActionCallback(action: ActionItem, entity: ClientDevice) { - switch (action.action) { - case Action.Delete: - this.deleteDevice(); - break; - case Action.Edit: + + handleActionCallback(event: ActionResult) { + switch (event.effect) { + case 'update': + // We have purposely encoded Edit as an Update // The actionable modal needs some time to clean up if (this.breakpointService.activeBreakpoint() < Breakpoint.Tablet) { setTimeout(() => this.toggleEdit(), 100); @@ -180,6 +180,12 @@ export class ClientDeviceCardComponent { this.toggleEdit(); } break; + case 'remove': + this.deviceDeleted.emit(event.entity.id); + break; + case 'reload': + case 'none': + break; } } @@ -191,20 +197,8 @@ export class ClientDeviceCardComponent { }); } - deleteDevice() { - const id = this.clientDevice().id; - this.deviceService.deleteClientDevice(id).subscribe(successful => { - if (successful) { - this.deviceDeleted.emit(id); - } - }); - } - toggleEdit() { this.deviceForm.get('name')!.setValue(this.clientDevice().friendlyName); this.isEditMode.update(x => !x); } - - - } diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.html b/UI/Web/src/app/_single-module/cover-image/cover-image.component.html index ffffa9736..482e5f8cf 100644 --- a/UI/Web/src/app/_single-module/cover-image/cover-image.component.html +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.html @@ -7,7 +7,7 @@
- +
diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss index a8071c571..848c092a6 100644 --- a/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss @@ -1,10 +1,10 @@ .overlay-information { position: relative; - top: -364px; - height: 364px; + top: -22.75rem; + height: 22.75rem; transition: all 0.2s; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; &:hover { cursor: pointer; @@ -17,9 +17,9 @@ .overlay-information--centered { position: absolute; - border-radius: 15px; + border-radius: 0.9375rem; background-color: rgba(0, 0, 0, 0.7); - border-radius: 50px; + border-radius: 3.125rem; top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -32,11 +32,11 @@ } div { - width: 60px; - height: 60px; + width: 3.75rem; + height: 3.75rem; i { font-size: 1.6rem; - line-height: 60px; + line-height: 3.75rem; width: 100%; } } @@ -46,12 +46,12 @@ .overlay-information { position: absolute; top: 0; - left: 12px; - width: calc(100% - 24px); + left: 0.75rem; + width: calc(100% - 1.5rem); height: 100%; transition: all 0.2s; - border-top-left-radius: 4px; - border-top-right-radius: 4px; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; &:hover { background-color: var(--card-overlay-hover-bg-color); @@ -64,9 +64,9 @@ .overlay-information--centered { position: absolute; - border-radius: 15px; + border-radius: 0.9375rem; background-color: rgba(0, 0, 0, 0.7); - border-radius: 50px; + border-radius: 3.125rem; top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -82,18 +82,18 @@ .series { .overlay-information--centered { div { - height: 32px; - width: 32px; + height: 2rem; + width: 2rem; i { font-size: 1.4rem; - line-height: 32px; + line-height: 2rem; } } } } ::ng-deep .image-container app-image img { - border-radius: 4px 4px 0 0; + border-radius: 0.25rem 0.25rem 0 0; } .progress { @@ -118,8 +118,8 @@ .continue-from { background-color: var(--breadcrumb-bg-color); color: white; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; + border-bottom-left-radius: 0.3125rem; + border-bottom-right-radius: 0.3125rem; text-align: center; position: absolute; width: 100%; @@ -129,7 +129,7 @@ overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; - padding: 0 10px 0 0; + padding: 0 0.625rem 0 0; } } diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts b/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts index 2622d0f1c..ce3206793 100644 --- a/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, EventEmitter, inject, input, Output} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, input, output} from '@angular/core'; import {DecimalPipe, DOCUMENT} from "@angular/common"; import {TranslocoDirective} from "@jsverse/transloco"; import {ImageComponent} from "../../shared/image/image.component"; @@ -28,7 +28,7 @@ export class CoverImageComponent { coverImage = input.required(); entity = input.required(); continueTitle = input(''); - @Output() read = new EventEmitter(); + readonly read = output(); mobileSeriesImgBackground = getComputedStyle(this.document.documentElement) .getPropertyValue('--mobile-series-img-background').trim(); diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index c25449495..4ec1dd735 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -4,7 +4,7 @@ @let filePathsValue = filePaths(); @let filesValue = files(); - @if (accountService.isAdmin() && (filePathsValue.length > 0 || filesValue.length > 0)) { + @if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {

{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}

@@ -24,8 +24,7 @@
} - @if (!suppressEmptyGenres || genres().length > 0) { - + @if (showGenres()) {

{{t('genres-title')}}

@@ -38,7 +37,7 @@
} - @if (!suppressEmptyTags || tags().length > 0) { + @if (showTags()) {

{{t('tags-title')}}

@@ -68,7 +67,7 @@
- + @@ -76,7 +75,7 @@
- + @@ -84,7 +83,7 @@
- + @@ -93,7 +92,7 @@
- + @@ -101,7 +100,7 @@
- + @@ -109,7 +108,7 @@
- + @@ -117,7 +116,7 @@
- + @@ -125,7 +124,7 @@
- + @@ -133,7 +132,7 @@
- + @@ -141,7 +140,7 @@
- + @@ -149,7 +148,7 @@
- + @@ -157,7 +156,7 @@
- + diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index b91553e7b..3fe82f5e4 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, computed, inject, input, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core'; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; import {TranslocoDirective} from "@jsverse/transloco"; @@ -41,12 +41,12 @@ export class DetailsTabComponent { protected readonly FilterField = FilterField; protected readonly MangaFormat = MangaFormat; - @Input({required: true}) metadata!: IHasCast; + metadata = input.required(); genres = input([]); tags = input([]); webLinks = input([]); - @Input() suppressEmptyGenres: boolean = false; - @Input() suppressEmptyTags: boolean = false; + suppressEmptyGenres = input(false); + suppressEmptyTags = input(false); filePaths = input([]); files = input([]); @@ -54,6 +54,9 @@ export class DetailsTabComponent { return this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0; }); + showTags = computed(() => !this.suppressEmptyTags() || this.tags().length > 0); + showGenres = computed(() => !this.suppressEmptyGenres() || this.genres().length > 0); + openGeneric(queryParamName: FilterField, filter: string | number) { if (queryParamName === FilterField.None) return; this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html index cdb49a7e6..c6f437239 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -9,10 +9,10 @@
} - @if (accountService.isAdmin$ | async) { + @if (accountService.hasAdminRole()) {
@@ -647,7 +647,7 @@ {{t(TabID.Tasks)}} @for(task of tasks; track task.action) { - @if (accountService.canInvokeAction(user, task.action)) { + @if (accountService.canCurrentUserInvokeAction(task.action)) {
diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 5ba192a62..dcc89da40 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -1,7 +1,16 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + inject, + Input, + OnInit +} from '@angular/core'; import {UtilityService} from "../../shared/_services/utility.service"; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; -import {AsyncPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; +import {NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; import {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective} from "@jsverse/transloco"; import {AccountService} from "../../_services/account.service"; @@ -16,7 +25,6 @@ import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; import {ImageService} from "../../_services/image.service"; import {UploadService} from "../../_services/upload.service"; import {MetadataService} from "../../_services/metadata.service"; -import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionService} from "../../_services/action.service"; import {DownloadService} from "../../shared/_services/download.service"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; @@ -37,8 +45,11 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {ReadTimePipe} from "../../_pipes/read-time.pipe"; import {ChapterService} from "../../_services/chapter.service"; import {AgeRating} from "../../_models/metadata/age-rating"; -import {User} from "../../_models/user/user"; import {BreakpointService} from "../../_services/breakpoint.service"; +import {ActionItem} from "../../_models/actionables/action-item"; +import {Action} from "../../_models/actionables/action"; +import {ActionFactoryService} from "../../_services/action-factory.service"; +import {modalDeleted, modalSaved} from "../../_models/modal/modal-result"; enum TabID { General = 'general-tab', @@ -50,14 +61,6 @@ enum TabID { Weblinks = 'weblinks-tab', // TODO: Weblinks are not implemented } -export interface EditChapterModalCloseResult { - success: boolean; - chapter: Chapter; - coverImageUpdate: boolean; - needsReload: boolean; - isDeleted: boolean; -} - const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; @Component({ @@ -68,7 +71,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; NgbNavContent, NgbNavLink, TranslocoDirective, - AsyncPipe, NgbNavOutlet, ReactiveFormsModule, NgbNavItem, @@ -127,20 +129,28 @@ export class EditChapterModalComponent implements OnInit { genres: Genre[] = []; ageRatings: Array = []; - tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList); + tasks = this.actionFactoryService.getActionablesForSettingsPage( + this.actionFactoryService.getChapterActions(this.seriesId, this.libraryId, this.libraryType), blackList); /** * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified */ initChapter!: Chapter; imageUrls: Array = []; size: number = 0; - user!: User; get WebLinks() { if (this.chapter.webLinks === '') return []; return this.chapter.webLinks.split(','); } + constructor() { + effect(() => { + if (!this.accountService.hasAdminRole()) { + this.activeId = TabID.Info; + this.cdRef.markForCheck(); + } + }); + } ngOnInit() { @@ -148,16 +158,6 @@ export class EditChapterModalComponent implements OnInit { this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0); - this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), tap(u => { - if (!u) return; - this.user = u; - - if (!this.accountService.hasAdminRole(this.user)) { - this.activeId = TabID.Info; - } - this.cdRef.markForCheck(); - - })).subscribe(); this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); this.editForm.addControl('sortOrder', new FormControl(Math.max(0, this.chapter.sortOrder), [Validators.required, Validators.min(0)])); @@ -262,7 +262,8 @@ export class EditChapterModalComponent implements OnInit { } concat(...apis).subscribe(results => { - this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult); + const needsCoverUpdate = selectedIndex > 0 || this.coverImageReset; + this.modal.close(modalSaved(model, needsCoverUpdate)); }); } @@ -291,7 +292,7 @@ export class EditChapterModalComponent implements OnInit { case Action.Delete: await this.actionService.deleteChapter(this.chapter.id, (b) => { if (!b) return; - this.modal.close({success: b, chapter: this.chapter, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditChapterModalCloseResult); + this.modal.close(modalDeleted(this.chapter)); }); break; case Action.Download: diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html index 9fe4a34fe..1188040b2 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html @@ -6,7 +6,7 @@
- @if (user && accountService.hasAdminRole(user)) { + @if (accountService.hasAdminRole()) { @for (file of files; track file.id) { @@ -92,7 +92,7 @@ - @if (user && accountService.hasAdminRole(user)) { + @if (accountService.hasAdminRole()) {
  • {{t(TabID.CoverImage)}} @@ -110,7 +110,7 @@ {{t(TabID.Tasks)}} @for(task of tasks; track task.action) { - @if (accountService.canInvokeAction(user, task.action)) { + @if (accountService.canCurrentUserInvokeAction(task.action)) {
    diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts index f7466473e..a7c9ea86e 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -12,7 +12,6 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {BytesPipe} from "../../_pipes/bytes.pipe"; import {ReadTimePipe} from "../../_pipes/read-time.pipe"; -import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {Volume} from "../../_models/volume"; import {UtilityService} from "../../shared/_services/utility.service"; import {ImageService} from "../../_services/image.service"; @@ -25,8 +24,11 @@ import {PersonRole} from "../../_models/metadata/person"; import {forkJoin} from "rxjs"; import {MangaFormat} from 'src/app/_models/manga-format'; import {MangaFile} from "../../_models/manga-file"; -import {User} from "../../_models/user/user"; import {BreakpointService} from "../../_services/breakpoint.service"; +import {ActionFactoryService} from "../../_services/action-factory.service"; +import {ActionItem} from "../../_models/actionables/action-item"; +import {Action} from "../../_models/actionables/action"; +import {modalDeleted, modalSaved} from "../../_models/modal/modal-result"; enum TabID { General = 'general-tab', @@ -36,15 +38,6 @@ enum TabID { Progress = 'progress-tab', } -export interface EditVolumeModalCloseResult { - success: boolean; - volume: Volume; - coverImageUpdate: boolean; - needsReload: boolean; - isDeleted: boolean; -} - -const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; @Component({ selector: 'app-edit-volume-modal', @@ -98,10 +91,8 @@ export class EditVolumeModalComponent implements OnInit { editForm: FormGroup = new FormGroup({}); selectedCover: string = ''; coverImageReset = false; - user!: User; - - - tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList); + + tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.seriesId, this.libraryId, this.libraryType), this.blacklist); /** * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified */ @@ -111,14 +102,14 @@ export class EditVolumeModalComponent implements OnInit { files: Array = []; constructor() { - this.accountService.currentUser$.subscribe(user => { - this.user = user!; - - if (!this.accountService.hasAdminRole(user!)) { - this.activeId = TabID.Info; - } + if (!this.accountService.hasAdminRole()) { + this.activeId = TabID.Info; this.cdRef.markForCheck(); - }); + } + } + + get blacklist() { + return [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; } @@ -147,7 +138,8 @@ export class EditVolumeModalComponent implements OnInit { } forkJoin(apis).subscribe(results => { - this.modal.close({success: true, volume: this.volume, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: false, isDeleted: false} as EditVolumeModalCloseResult); + const needsCoverUpdate = selectedIndex > 0 || this.coverImageReset; + this.modal.close(modalSaved(this.volume, needsCoverUpdate)); }); } @@ -169,7 +161,7 @@ export class EditVolumeModalComponent implements OnInit { case Action.Delete: await this.actionService.deleteVolume(this.volume.id, (b) => { if (!b) return; - this.modal.close({success: b, volume: this.volume, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditVolumeModalCloseResult); + this.modal.close(modalDeleted(this.volume)); }); break; case Action.Download: diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html index 586829841..7cc9992bb 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -2,7 +2,7 @@
    @@ -48,11 +48,11 @@
    @if (!formGroup.get('dontMatch')?.value) { - - @for(item of matches; track item.series.name) { + + @for(item of matches(); track item.series.name) { } @empty { - @if (!isLoading) { + @if (!isLoading()) {

    {{t('no-results')}}

    } } diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts index d096c91dc..75c4627da 100644 --- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, OnInit, signal} from '@angular/core'; import {Series} from "../../_models/series"; import {SeriesService} from "../../_services/series.service"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; @@ -10,8 +10,8 @@ import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-m import {ToastrService} from "ngx-toastr"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import { ThemeService } from 'src/app/_services/theme.service'; -import { AsyncPipe } from '@angular/common'; +import {ThemeService} from 'src/app/_services/theme.service'; +import {AsyncPipe} from '@angular/common'; import {catchError, of, tap} from "rxjs"; @Component({ @@ -36,59 +36,56 @@ export class MatchSeriesModalComponent implements OnInit { private readonly toastr = inject(ToastrService); protected readonly themeService = inject(ThemeService); - @Input({required: true}) series!: Series; + series = input.required(); formGroup = new FormGroup({}); - matches: Array = []; - isLoading = true; + matches = signal([]); + isLoading = signal(true); ngOnInit() { this.formGroup.addControl('query', new FormControl('', [])); - this.formGroup.addControl('dontMatch', new FormControl(this.series?.dontMatch || false, [])); + this.formGroup.addControl('dontMatch', new FormControl(this.series().dontMatch || false, [])); this.search(); } search() { - this.isLoading = true; - this.cdRef.markForCheck(); + this.isLoading.set(true); const model: any = this.formGroup.value; - model.seriesId = this.series.id; + model.seriesId = this.series().id; if (model.dontMatch) { - this.isLoading = false; + this.isLoading.set(false); return; } this.seriesService.matchSeries(model).pipe( tap(results => { - this.isLoading = false; - this.matches = results; - this.cdRef.markForCheck(); + this.isLoading.set(false); + this.matches.set(results); }), catchError(() => { - this.isLoading = false; - this.cdRef.markForCheck(); + this.isLoading.set(false); return of([]); }) ).subscribe(); } close() { - this.modalService.close(false); + this.modalService.dismiss(); } save() { const model: any = this.formGroup.value; - model.seriesId = this.series.id; + model.seriesId = this.series().id; - const dontMatchChanged = this.series.dontMatch !== model.dontMatch; + const dontMatchChanged = this.series().dontMatch !== model.dontMatch; // We need to update the dontMatch status if (dontMatchChanged) { - this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => { + this.seriesService.updateDontMatch(this.series().id, model.dontMatch).subscribe(_ => { this.modalService.close(true); }); } else { @@ -102,7 +99,7 @@ export class MatchSeriesModalComponent implements OnInit { data.tags = data.tags || []; data.genres = data.genres || []; - this.seriesService.updateMatch(this.series.id, item.series).subscribe(_ => { + this.seriesService.updateMatch(this.series().id, item.series).subscribe(_ => { this.save(); }); } diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html index c0fdbbd55..336bf448c 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -1,49 +1,50 @@ -
    +
    - @if (item.series.coverUrl) { - + @let coverUrl = item().series.coverUrl; + @if (coverUrl) { + }
    -
    {{item.series.name}} ({{item.matchRating | translocoPercent}})
    +
    {{item().series.name}} ({{item().matchRating | translocoPercent}})
    - @for(synm of item.series.synonyms; track synm; let last = $last) { + @for(synm of item().series.synonyms; track synm; let last = $last) { {{synm}} @if (!last) { , } }
    - @if (item.series.summary) { + @if (item().series.summary) { }
    - @if (isSelected) { + @if (isSelected()) {
    {{t('updating-metadata-status')}}
    } @else {
    - @if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) { - @if (item.series.plusMediaFormat === PlusMediaFormat.Comic) { - {{t('issue-count', {num: item.series.chapters})}} + @if ((item().series.volumes || 0) > 0 || (item().series.chapters || 0) > 0) { + @if (item().series.plusMediaFormat === PlusMediaFormat.Comic) { + {{t('issue-count', {num: item().series.chapters})}} } @else { - {{t('volume-count', {num: item.series.volumes})}} - {{t('chapter-count', {num: item.series.chapters})}} + {{t('volume-count', {num: item().series.volumes})}} + {{t('chapter-count', {num: item().series.chapters})}} } } @else { {{t('releasing')}} } - {{item.series.plusMediaFormat | plusMediaFormat}} + {{item().series.plusMediaFormat | plusMediaFormat}}
    } diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss index 5df806397..a6360d9c6 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss @@ -1,7 +1,7 @@ .search-result { img { - max-width: 100px; - min-width: 100px; + max-width: 6.25rem; + min-width: 6.25rem; } } .title { @@ -19,7 +19,7 @@ &.light { background-color: var(--elevation-layer6); } - border-radius: 15px; + border-radius: 0.9375rem; &:hover { &.dark { diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts index 9e3044884..ad57702f7 100644 --- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -1,12 +1,4 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - inject, - Input, - Output -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, input, output, signal} from '@angular/core'; import {ImageComponent} from "../../shared/image/image.component"; import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; @@ -32,20 +24,18 @@ import {PlusMediaFormat} from "../../_models/series-detail/external-series-detai }) export class MatchSeriesResultItemComponent { - private readonly cdRef = inject(ChangeDetectorRef); + item = input.required(); + isDarkMode = input(true); + selected = output(); - @Input({required: true}) item!: ExternalSeriesMatch; - @Input({required: true}) isDarkMode = true; - @Output() selected: EventEmitter = new EventEmitter(); - - isSelected = false; + isSelected = signal(false); selectItem() { - if (this.isSelected) return; + if (this.isSelected()) return; - this.isSelected = true; - this.cdRef.markForCheck(); - this.selected.emit(this.item); + this.isSelected.set(true); + + this.selected.emit(this.item()); } protected readonly PlusMediaFormat = PlusMediaFormat; diff --git a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html index 4a7fe4f6d..fb67b8934 100644 --- a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html +++ b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.html @@ -11,5 +11,5 @@ [hideOnError]="true" /> } @else { - + } diff --git a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.scss b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.scss index 8b1378917..ffe1ec588 100644 --- a/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.scss +++ b/UI/Web/src/app/_single-module/profile-icon/profile-icon.component.scss @@ -1 +1,3 @@ - +.profile-icon { + color: var(--body-text-color); +} diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html index b9a2e88a2..55f55d14d 100644 --- a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html @@ -1,34 +1,38 @@ -@if (publishers.length > 0) { +@let currentPublisherValue = currentPublisher(); +@let nextPublisherValue = nextPublisher(); + +@if (currentPublisherValue) {
    -
    +
    -
    -
    -
    -
    } diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss index 9f4486d16..686ef9bc0 100644 --- a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss @@ -15,18 +15,18 @@ // Animation code .publisher-wrapper { - perspective: 1000px; - height: 32px; + perspective: 62.5rem; + height: 2rem; background-color: var(--card-bg-color); - border-radius: 3px; - padding: 2px 5px; + border-radius: 0.1875rem; + padding: 0.125rem 0.3125rem; font-size: 0.8rem; vertical-align: middle; div { - min-height: 32px; - line-height: 32px; + min-height: 2rem; + line-height: 2rem; } } diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts index fe54cdaa1..61f336fab 100644 --- a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts @@ -1,21 +1,10 @@ -import { - AfterViewChecked, - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - inject, - Input, - OnDestroy, - OnInit -} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal} from '@angular/core'; import {ImageComponent} from "../../shared/image/image.component"; import {FilterField} from "../../_models/metadata/v2/filter-field"; import {Person} from "../../_models/metadata/person"; import {ImageService} from "../../_services/image.service"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; -import {Router} from "@angular/router"; const ANIMATION_TIME = 3000; @@ -28,58 +17,48 @@ const ANIMATION_TIME = 3000; styleUrl: './publisher-flipper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class PublisherFlipperComponent implements OnInit, OnDestroy, AfterViewInit, AfterViewChecked { +export class PublisherFlipperComponent { protected readonly imageService = inject(ImageService); private readonly filterUtilityService = inject(FilterUtilitiesService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); - @Input() publishers: Array = []; + publishers = input([]); - currentPublisher: Person | undefined = undefined; - nextPublisher: Person | undefined = undefined; + currentIndex = signal(0); + isFlipped = signal(false); - currentIndex = 0; - isFlipped = false; - private intervalId: any; + currentPublisher = computed(() => { + const publishers = this.publishers(); + if (publishers.length === 0) return undefined; + return publishers[this.currentIndex() % publishers.length]; + }); - ngOnInit() { - if (this.publishers.length > 0) { - this.currentPublisher = this.publishers[0]; - this.nextPublisher = this.publishers[1] || this.publishers[0]; - } - } + nextPublisher = computed(() => { + const publishers = this.publishers(); + if (publishers.length === 0) return undefined; + if (publishers.length === 1) return publishers[0]; + return publishers[(this.currentIndex() + 1) % publishers.length]; + }); - ngAfterViewInit() { - if (this.publishers.length > 1) { - this.startFlipping(); // Start flipping cycle once the view is initialized - } - } + constructor() { + // Start flipping when publishers has more than 1 item + effect(() => { + const publishers = this.publishers(); + if (publishers.length <= 1) return; - ngAfterViewChecked() { - // This lifecycle hook will be called after Angular performs change detection in each cycle - if (this.isFlipped) { - // Only update publishers after the flip is complete - this.currentIndex = (this.currentIndex + 1) % this.publishers.length; - this.currentPublisher = this.publishers[this.currentIndex]; - this.nextPublisher = this.publishers[(this.currentIndex + 1) % this.publishers.length]; - } - } + const intervalId = setInterval(() => { + this.isFlipped.update(v => !v); - ngOnDestroy() { - if (this.intervalId) { - clearInterval(this.intervalId); - } - } + // Advance index on every other toggle (when flipping back) + if (!this.isFlipped()) { + this.currentIndex.update(i => (i + 1) % publishers.length); + } + }, ANIMATION_TIME); - private startFlipping() { - this.intervalId = setInterval(() => { - // Toggle flip state, initiating the flip animation - this.isFlipped = !this.isFlipped; - this.cdRef.detectChanges(); // Explicitly detect changes to trigger re-render - }, ANIMATION_TIME); + this.destroyRef.onDestroy(() => clearInterval(intervalId)); + }); } openPublisher(filter: string | number) { diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.html b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html index f1a1c99a7..f3469fc97 100644 --- a/UI/Web/src/app/_single-module/related-tab/related-tab.component.html +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html @@ -1,45 +1,49 @@
    - @if (relations.length > 0) { - - - + @if (relationEntities().length > 0) { + + + } - @if (collections.length > 0) { - - - + @if (collectionEntities().length > 0) { + + + + + + + + {{entity.data.title}} + + } - @if (readingLists.length > 0) { - - - + @if (readingListEntities().length > 0) { + + + + + {{entity.data.title}} + + } - @if (bookmarks.length > 0) { - - - + @if (bookmarks().length > 0) { + + + } diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts index 8d8a767d5..a6c1507e7 100644 --- a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts @@ -1,54 +1,83 @@ -import {ChangeDetectionStrategy, Component, inject, Input, OnInit} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, inject, input, output} from '@angular/core'; import {ReadingList} from "../../_models/reading-list"; -import {CardItemComponent} from "../../cards/card-item/card-item.component"; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; import {ImageService} from "../../_services/image.service"; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {UserCollection} from "../../_models/collection-tag"; -import {Router} from "@angular/router"; -import {SeriesCardComponent} from "../../cards/series-card/series-card.component"; import {Series} from "../../_models/series"; import {RelationKind} from "../../_models/series-detail/relation-kind"; import {PageBookmark} from "../../_models/readers/page-bookmark"; +import {CardConfigFactory} from "../../_services/card-config-factory.service"; +import {EntityCardComponent} from "../../cards/entity-card/entity-card.component"; +import {CardEntityFactory} from "../../_models/card/card-entity"; +import {CollectionOwnerComponent} from "../../collections/_components/collection-owner/collection-owner.component"; +import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; export interface RelatedSeriesPair { series: Series; relation: RelationKind; } +/** + * Fires when a card on Related Tab is mutated or deleted + */ +export interface RelatedTabChangeEvent { + entity: 'bookmark' | 'collection' | 'readingList' | 'relation'; + /** + * Entity Id - Relation's will have the underlying seriesId + */ + id: number; +} + @Component({ - selector: 'app-related-tab', - imports: [ - CardItemComponent, - CarouselReelComponent, - TranslocoDirective, - SeriesCardComponent - ], - templateUrl: './related-tab.component.html', - styleUrl: './related-tab.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-related-tab', + imports: [ + CarouselReelComponent, + TranslocoDirective, + EntityCardComponent, + CollectionOwnerComponent, + PromotedIconComponent + ], + templateUrl: './related-tab.component.html', + styleUrl: './related-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) export class RelatedTabComponent { protected readonly imageService = inject(ImageService); - protected readonly router = inject(Router); + private readonly cardConfigFactory = inject(CardConfigFactory); - @Input() readingLists: Array = []; - @Input() collections: Array = []; - @Input() relations: Array = []; - @Input() bookmarks: Array = []; - @Input() libraryId!: number; + readingLists = input([]); + readingListEntities = computed(() => this.readingLists().map(r => CardEntityFactory.readingList(r))); + readingListConfig = computed(() => this.cardConfigFactory.forReadingList({overrides: { + actionableFunc: () => [], + allowSelection: false + }})); - openReadingList(readingList: ReadingList) { - this.router.navigate(['lists', readingList.id]); - } + collections = input([]); + collectionEntities = computed(() => this.collections().map(c => CardEntityFactory.collection(c))); + collectionConfig = computed(() => this.cardConfigFactory.forCollection({overrides: {allowSelection: false}})) - openCollection(collection: UserCollection) { - this.router.navigate(['collections', collection.id]); - } - viewBookmark(bookmark: PageBookmark) { - this.router.navigate(['library', this.libraryId, 'series', bookmark.seriesId, 'manga', 0], {queryParams: {incognitoMode: false, bookmarkMode: true}}); - } + bookmarks = input([]); + bookmarkEntities = computed(() => this.bookmarks().map(b => CardEntityFactory.bookmark(b))); + bookmarkConfig = computed(() => { + return this.cardConfigFactory.forBookmark({ + overrides: { + titleFunc: (d) => translate('related-tab.bookmarks-title'), + coverFunc: (d) => this.imageService.getSeriesCoverImage(d.seriesId), + metaTitleFunc: d => '', + allowSelection: false + } + }); + }) + relations = input([]); + relationEntities = computed(() => this.relations().map(r => CardEntityFactory.related(r))); + relatedConfig = computed(() => this.cardConfigFactory.forRelationship()); + + /** Emits when an entity type is deleted and a full refresh is needed **/ + readonly reload = output(); + /** Emits when an entity's internal state is changed and it needs to be updated **/ + readonly dataChanged = output(); } diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html index ddc78cbc8..8796fcd7d 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html @@ -2,22 +2,22 @@