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