Massive UI Cleanup (#4466)

Co-authored-by: KindlyFire <10267586+kindlyfire@users.noreply.github.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alexey <lewadedun@gmail.com>
Co-authored-by: Anon Bitardov <timurvolga23+weblate@gmail.com>
Co-authored-by: Ferran <ferrancette@gmail.com>
Co-authored-by: Gneb <goozi12345@gmail.com>
Co-authored-by: Robin Stolpe <robinstolpe@slashmad.com>
Co-authored-by: 안세훈 <on9686@gmail.com>
Co-authored-by: Tijl Van den Brugghen <contact@tijlvdb.me>
This commit is contained in:
Joe Milazzo
2026-02-28 13:19:00 -06:00
committed by GitHub
parent faae7fe402
commit 0bbb0ff28f
596 changed files with 14339 additions and 12501 deletions
+8 -6
View File
@@ -66,10 +66,12 @@ public class CollectionController : BaseApiController
/// <param name="collectionId"></param>
/// <returns></returns>
[HttpGet("single")]
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetTag(int collectionId)
public async Task<ActionResult<AppUserCollectionDto>> 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);
}
/// <summary>
@@ -101,10 +103,10 @@ public class CollectionController : BaseApiController
/// <remarks>UI does not contain controls to update title</remarks>
/// </summary>
/// <param name="updatedTag"></param>
/// <returns></returns>
/// <returns>The updated tag entity</returns>
[HttpPost("update")]
[DisallowRole(PolicyConstants.ReadOnlyRole)]
public async Task<ActionResult> UpdateTag(AppUserCollectionDto updatedTag)
public async Task<ActionResult<AppUserCollectionDto>> 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)
+14 -15
View File
@@ -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.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
/// <returns>Created Library</returns>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[HttpPost("create")]
public async Task<ActionResult> AddLibrary(UpdateLibraryDto dto)
public async Task<ActionResult<LibraryDto?>> 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));
}
/// <summary>
@@ -207,17 +208,18 @@ public class LibraryController : BaseApiController
/// <summary>
/// Return a specific library
/// </summary>
/// <remarks>If the user is not an admin, only id, type, and name will be returned</remarks>
/// <returns></returns>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[ProducesResponseType<LibraryDto>(StatusCodes.Status200OK)]
[ProducesResponseType<LiteLibraryDto>(StatusCodes.Status200OK)]
[HttpGet]
public async Task<ActionResult<LibraryDto?>> 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));
}
/// <summary>
@@ -227,10 +229,7 @@ public class LibraryController : BaseApiController
[HttpGet("libraries")]
public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibraries()
{
var username = Username!;
if (string.IsNullOrEmpty(username)) return Unauthorized();
return Ok(await GetLibrariesForUser(username));
return Ok(await GetLibrariesForUser(Username!));
}
/// <summary>
@@ -674,7 +673,7 @@ public class LibraryController : BaseApiController
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
return Ok();
return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtoByIdAsync(library.Id));
}
+4 -4
View File
@@ -159,9 +159,9 @@ public class SeriesController : BaseApiController
/// Updates the Series
/// </summary>
/// <param name="updateSeries"></param>
/// <returns></returns>
/// <returns>Updated Series</returns>
[HttpPost("update")]
public async Task<ActionResult> UpdateSeries(UpdateSeriesDto updateSeries)
public async Task<ActionResult<SeriesDto>> 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));
}
/// <summary>
@@ -233,7 +233,7 @@ public class SeriesController : BaseApiController
/// <param name="userParams">Page size and offset</param>
/// <returns></returns>
[HttpPost("recently-updated-series")]
public async Task<ActionResult<IList<RecentlyAddedItemDto>>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams)
public async Task<ActionResult<IList<GroupedSeriesDto>>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(UserId, userParams));
@@ -1,34 +0,0 @@
using System;
using API.Entities.Enums;
namespace API.DTOs.Dashboard;
/// <summary>
/// A mesh of data for Recently added volume/chapters
/// </summary>
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; }
/// <summary>
/// This will automatically map to Volume X, Chapter Y, etc.
/// </summary>
public string Title { get; set; } = default!;
public DateTime Created { get; set; }
/// <summary>
/// Chapter Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int ChapterId { get; set; } = 0;
/// <summary>
/// Volume Id if this is a chapter. Not guaranteed to be set.
/// </summary>
public int VolumeId { get; set; } = 0;
/// <summary>
/// This is used only on the UI. It is just index of being added.
/// </summary>
public int Id { get; set; }
public MangaFormat Format { get; set; }
}
+9 -2
View File
@@ -6,15 +6,22 @@ using API.Entities.Enums;
namespace API.DTOs;
#nullable enable
public sealed record LibraryDto
/// <summary>
/// This is a LibraryDto that non-admins can resolve that has the core information they need
/// </summary>
public record LiteLibraryDto
{
public int Id { get; init; }
public string? Name { get; init; }
public LibraryType Type { get; init; }
}
public sealed record LibraryDto : LiteLibraryDto
{
/// <summary>
/// Last time Library was scanned
/// </summary>
public DateTime LastScanned { get; init; }
public LibraryType Type { get; init; }
/// <summary>
/// An optional Cover Image or null
/// </summary>
+1 -1
View File
@@ -231,7 +231,7 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.LibraryName,
opt => opt.MapFrom(src => src.Library.Name));
CreateMap<Library, LiteLibraryDto>();
CreateMap<Library, LibraryDto>()
.ForMember(dest => dest.Folders,
opt =>
@@ -50,6 +50,11 @@ public interface ICollectionTagRepository
/// <param name="includePromoted"></param>
/// <returns></returns>
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false);
/// <summary>
/// Returns the collection if the user owns it or the collection is promoted
/// </summary>
/// <returns></returns>
Task<AppUserCollectionDto?> GetCollectionDtoAsync(int collectionId, int userId);
Task<PagedList<AppUserCollectionDto>> GetCollectionDtosPagedAsync(int userId, UserParams userParams, bool includePromoted = false);
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false);
@@ -108,6 +113,17 @@ public class CollectionTagRepository : ICollectionTagRepository
.ToListAsync();
}
public async Task<AppUserCollectionDto?> 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<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
@@ -37,6 +37,8 @@ public interface ILibraryRepository
void Update(Library library);
void Delete(Library? library);
Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync();
Task<LibraryDto?> GetLibraryDtoByIdAsync(int libraryId);
Task<LiteLibraryDto?> GetLiteLibraryDtoByIdAsync(int libraryId);
Task<bool> LibraryExists(string libraryName);
Task<Library?> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None);
Task<IList<LibraryDto>> GetLibraryDtosForUsernameAsync(string userName);
@@ -214,6 +216,23 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync();
}
public async Task<LibraryDto?> GetLibraryDtoByIdAsync(int libraryId)
{
return await _context.Library
.Include(f => f.Folders)
.Include(l => l.LibraryFileTypes)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.FirstOrDefaultAsync(l => l.Id == libraryId);
}
public async Task<LiteLibraryDto?> GetLiteLibraryDtoByIdAsync(int libraryId)
{
return await _context.Library
.ProjectTo<LiteLibraryDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(l => l.Id == libraryId);
}
public async Task<Library?> GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None)
{
@@ -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),
+5 -1
View File
@@ -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}"
}
+1 -1
View File
@@ -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 ú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",
+3 -3
View File
@@ -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": "Коллекция с таким именем уже существует",
+4 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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"
}
+3 -3
View File
@@ -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);
}
}
+1 -1
View File
@@ -292,7 +292,7 @@ public class LocalizationService : ILocalizationService
try
{
var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-'));
return cultureInfo.NativeName;
return cultureInfo.EnglishName;
}
catch
{
+4
View File
@@ -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.
+1 -1
View File
@@ -20,4 +20,4 @@
</PackageReference>
<PackageReference Include="xunit.assert" Version="2.9.3" />
</ItemGroup>
</Project>
</Project>
+38 -38
View File
@@ -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;
}
}
}
+12 -12
View File
@@ -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%);
}
}
+21 -21
View File
@@ -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;
+2 -2
View File
@@ -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;
@@ -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<Event>();
@Output() doubleClick = new EventEmitter<Event>();
readonly singleClick = output<Event>();
readonly doubleClick = output<Event>();
private lastTapTime = 0;
private tapTimeout = 300; // Time threshold for a double tap (in milliseconds)
@@ -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();
}
+13 -26
View File
@@ -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<boolean> {
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']);
};
+18 -33
View File
@@ -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<boolean> {
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']);
};
+19 -14
View File
@@ -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<boolean> {
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'))
);
};
+1 -1
View File
@@ -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;
}
@@ -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') {
@@ -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({
@@ -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<T> = (action: ActionItem<T>, entity: T) => void;
export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
export interface ActionItem<T> {
title: string;
description: string;
action: Action;
callback: ActionResultCallback<T>;
/**
* 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<ActionItem<T>>;
/**
* 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<T>;
}
@@ -0,0 +1,12 @@
import {Action} from "./action";
import {ActionItem} from "./action-item";
import {Observable} from "rxjs";
export type ActionResultCallback<T> = (action: ActionItem<T>, entity: T) => Observable<ActionResult<T>>;
export type ActionEffect = 'update' | 'remove' | 'reload' | 'none';
export interface ActionResult<T> {
action: Action;
entity: T;
effect: ActionEffect;
}
@@ -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,
}
@@ -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<T> {
/** 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<DownloadEvent | null>;
/**
* Returns key/values for route params (bookmark mode)
*/
titleRouteParamsFunc?: (entity: T) => Record<string, any>;
/**
* Optional strategy for handling real-time progress updates.
* If not provided, the card ignores progress events.
*/
progressUpdateStrategy?: ProgressUpdateStrategy<T>;
}
/**
* 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<T extends ActionableEntity>
extends BaseCardConfiguration<T> {
/** Action items for the card's action menu */
actionableFunc: (entity: T) => ActionItem<T>[];
}
export type CardConfiguration<T> = T extends ActionableEntity
? ActionableCardConfiguration<T> | BaseCardConfiguration<T>
: BaseCardConfiguration<T>;
export function hasActionables<T>(
config: BaseCardConfiguration<T>
): config is BaseCardConfiguration<T> & { actionableFunc: (entity: any) => ActionItem<any>[] } {
return (
'actionableFunc' in config &&
typeof (config as any).actionableFunc === 'function'
);
}
/**
* Partial configuration for overrides. All properties optional.
*/
export type CardConfigurationOverrides<T extends ActionableEntity> = Partial<ActionableCardConfiguration<T>>;
export type BaseCardConfigurationOverrides<T> = Partial<BaseCardConfiguration<T>>;
/**
* 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<T> {
/**
* 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<T> {
/** 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;
}
+167
View File
@@ -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<T extends CardEntity>(entity: T): T['data'] {
return entity.data;
}
/**
* Helper to create CardEntity wrappers (useful for UI patching)
*/
export const CardEntityFactory = {
series: (data: Series, context?: Partial<Omit<SeriesCardEntity, 'entityType' | 'data'>>): 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<Omit<ChapterCardEntity, 'entityType' | 'data' | 'seriesId' | 'libraryId'>>): 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
}),
};
@@ -1 +0,0 @@
export const DefaultModalOptions = {scrollable: true, size: 'xl', fullscreen: 'xl'};
+8 -2
View File
@@ -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;
@@ -0,0 +1,45 @@
import {NgbModalOptions} from "@ng-bootstrap/ng-bootstrap";
export const DefaultModalOptions: Partial<NgbModalOptions> = {
scrollable: true,
size: 'xl',
fullscreen: 'xl',
};
/** Any Edit Entity modal should use this */
export function editModal(): Partial<NgbModalOptions> {
return {...DefaultModalOptions, size: 'xl', fullscreen: 'xl'};
}
export function mediumModal(): Partial<NgbModalOptions> {
return {...DefaultModalOptions, size: 'md', fullscreen: 'sm'};
}
export function confirmModal(): Partial<NgbModalOptions> {
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<NgbModalOptions> {
return {...DefaultModalOptions, size: 'md', fullscreen: 'sm'};
}
/** Non-dismissible — for refresh-required modals only */
export function versionRefreshModal(): Partial<NgbModalOptions> {
return {
...DefaultModalOptions,
size: 'lg',
keyboard: false,
scrollable: true,
backdrop: 'static'
};
}
/** Dismissible — for update-available and out-of-date modals */
export function versionNotifyModal(): Partial<NgbModalOptions> {
return {
...DefaultModalOptions,
size: 'lg',
scrollable: true,
};
}
@@ -0,0 +1,18 @@
export interface ModalResult<T = void> {
success: boolean;
data?: T;
coverImageUpdated?: boolean;
isDeleted?: boolean;
}
export function modalSaved<T>(data?: T, coverImageUpdated = false): ModalResult<T> {
return { success: true, data, coverImageUpdated, isDeleted: false };
}
export function modalDeleted<T>(data?: T): ModalResult<T> {
return { success: true, data, coverImageUpdated: false, isDeleted: true };
}
export function modalCancelled<T>(): ModalResult<T> {
return { success: false };
}
+3 -1
View File
@@ -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;
}
+1
View File
@@ -32,6 +32,7 @@ export interface Series extends IHasCover, IHasReadingTime, IHasProgress {
userRating: number;
hasUserRated: boolean;
libraryId: number;
libraryName: string;
/**
* DateTime the entity was created
*/
-2
View File
@@ -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;
@@ -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<Chapter | UrlTree> = (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'));
})
);
};
@@ -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<UserCollection | UrlTree> = (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'));
})
);
};
@@ -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<Library | UrlTree> = (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'));
})
);
};
@@ -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<Person | UrlTree> = (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'));
})
);
};
@@ -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<ReadingList | UrlTree> = (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'));
})
);
};
@@ -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<Series | UrlTree> = (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'));
})
);
};
@@ -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<Volume | UrlTree> = (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'));
})
);
};
@@ -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
@@ -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
},
@@ -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,
@@ -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',
},
];
@@ -6,5 +6,6 @@ export const routes: Routes = [
{
path: '',
component: DashboardComponent,
title: 'title.home',
}
];
@@ -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
},
},
}
}
];
@@ -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
}
];
@@ -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),
}
];
@@ -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'},
];
@@ -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
}
},
+88 -144
View File
@@ -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<User | undefined>(1);
public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
public isAdmin$: Observable<boolean> = 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<User | undefined>(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<Role> = []) {
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<Role[]>(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<User, 'authKeys'> {
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<string>(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<string>(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse);
}
@@ -437,32 +392,28 @@ export class AccountService {
*/
getPreferences() {
return this.httpClient.get<Preferences>(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<Preferences>(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<User>(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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -6,7 +6,6 @@ import {TextResonse} from "../_types/text-response";
import {asyncScheduler, map, of, tap} from "rxjs";
import {switchMap, throttleTime} from "rxjs/operators";
import {AccountService} from "./account.service";
import {User} from "../_models/user/user";
import {MessageHubService} from "./message-hub.service";
import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot";
import {Router} from "@angular/router";
@@ -50,21 +49,15 @@ export class AnnotationService {
private _events = signal<AnnotationEvent | null>(null);
public readonly events = this._events.asReadonly();
private readonly user = signal<User | null>(null);
public readonly slots = computed(() => {
const currentUser = this.user();
const currentUser = this.accountService.currentUser();
return currentUser?.preferences?.bookReaderHighlightSlots ?? [];
});
constructor() {
this.accountService.currentUser$.subscribe(user => {
this.user.set(user!);
});
}
updateSlotColor(index: number, color: RgbaColor) {
const user = this.accountService.currentUserSignal();
const user = this.accountService.currentUser();
if (!user) return of([]);
const preferences = user.preferences;
@@ -206,7 +199,7 @@ export class AnnotationService {
* @param ids
*/
likeAnnotations(ids: number[]) {
const userId = this.accountService.currentUserSignal()?.id;
const userId = this.accountService.currentUser()?.id;
if (!userId) return of();
return this.httpClient.post(this.baseUrl + 'annotation/like', ids);
@@ -217,7 +210,7 @@ export class AnnotationService {
* @param ids
*/
unLikeAnnotations(ids: number[]) {
const userId = this.accountService.currentUserSignal()?.id;
const userId = this.accountService.currentUser()?.id;
if (!userId) return of();
return this.httpClient.post(this.baseUrl + 'annotation/unlike', ids);
@@ -0,0 +1,431 @@
import {inject, Injectable, TemplateRef} from "@angular/core";
import {ImageService} from "./image.service";
import {ReaderService} from "./reader.service";
import {ActionableEntity, ActionFactoryService} from "./action-factory.service";
import {DownloadService} from "../shared/_services/download.service";
import {Router} from "@angular/router";
import {RelationshipPipe} from "../_pipes/relationship.pipe";
import {Series} from "../_models/series";
import {CardEntity, ChapterCardEntity, RelatedSeriesCardEntity, SeriesCardEntity} from "../_models/card/card-entity";
import {
ActionableCardConfiguration,
BaseCardConfiguration,
BaseCardConfigurationOverrides,
CardConfigurationOverrides
} from "../_models/card/card-configuration";
import {Chapter, LooseLeafOrDefaultNumber} from "../_models/chapter";
import {map} from "rxjs/operators";
import {Volume} from "../_models/volume";
import {UserCollection} from "../_models/collection-tag";
import {ReadingList} from "../_models/reading-list";
import {LibraryType} from "../_models/library/library";
import {MangaFormat} from "../_models/manga-format";
import {User} from "../_models/user/user";
import {PageBookmark} from "../_models/readers/page-bookmark";
import {RelatedSeriesPair} from "../_single-module/related-tab/related-tab.component";
import {ActionItem} from "../_models/actionables/action-item";
import {SeriesGroup} from "../_models/series-group";
export interface ConfigCardFactoryBaseParameters<T> {
shouldRenderAction?: (action: ActionItem<T>, entity: T, user: User) => boolean,
titleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined,
metaTitleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined,
overrides?: BaseCardConfigurationOverrides<T>,
}
export interface ConfigCardFactoryActionableParameters<T extends ActionableEntity> {
shouldRenderAction?: (action: ActionItem<T>, entity: T, user: User) => boolean,
titleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined,
metaTitleRef?: TemplateRef<{ $implicit: CardEntity }> | undefined,
overrides?: CardConfigurationOverrides<T>,
}
export interface ConfigCardFactoryChapterVolumeParameters<T extends ActionableEntity> extends ConfigCardFactoryActionableParameters<T> {
seriesId: number;
libraryId: number;
libraryType: number;
}
/**
* Factory service that creates CardConfiguration objects for each entity type.
* Provides sensible defaults that can be overridden at call sites.
*
* Usage:
* // In component
* private configFactory = inject(CardConfigFactory);
*
* config = computed(() => this.configFactory.forSeries({
* allowSelection: true,
* actionables: this.customActions
* }));
*/
@Injectable({ providedIn: 'root' })
export class CardConfigFactory {
private readonly imageService = inject(ImageService);
private readonly readerService = inject(ReaderService);
private readonly actionFactory = inject(ActionFactoryService);
private readonly downloadService = inject(DownloadService);
private readonly router = inject(Router);
private readonly relationshipPipe = new RelationshipPipe();
/**
* Creates configuration for Series cards
*/
forSeries(
params?: ConfigCardFactoryActionableParameters<Series>
): ActionableCardConfiguration<Series> {
const defaults: ActionableCardConfiguration<Series> = {
allowSelection: false,
selectionType: 'series',
suppressArchiveWarning: false,
coverFunc: (s) => this.imageService.getSeriesCoverImage(s.id),
titleFunc: (s) => s.name,
titleRouteFunc: (s) => `/library/${s.libraryId}/series/${s.id}`,
metaTitleFunc: (s, wrapper) => {
const seriesWrapper = wrapper as SeriesCardEntity;
if (seriesWrapper.relation) {
return this.relationshipPipe.transform(seriesWrapper.relation);
}
return s.localizedName || s.name;
},
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
tooltipFunc: (s) => s.name,
progressFunc: (s) => ({ pages: s.pages, pagesRead: s.pagesRead }),
formatBadgeFunc: (s) => s.format,
countFunc: () => 0,
showErrorFunc: (s) => s.pages === 0,
ariaLabelFunc: (s) => s.name,
actionableFunc: (s) => this.actionFactory.getSeriesActions(),
readFunc: (s) => this.readerService.readSeries(s, false),
clickFunc: (s) => this.router.navigate(['library', s.libraryId, 'series', s.id]),
downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe(
map(events => this.downloadService.mapToEntityType(events, s))
),
progressUpdateStrategy: {
getMatchCriteria: (s) => ({ seriesId: s.id }),
// Series cards don't contain chapter/volume details
// Signal that parent needs to refetch
applyUpdate: () => null
}
};
return this.mergeConfig(defaults, params?.overrides);
}
forRelationship(
params?: ConfigCardFactoryBaseParameters<RelatedSeriesPair>
): BaseCardConfiguration<RelatedSeriesPair> {
const defaults: BaseCardConfiguration<RelatedSeriesPair> = {
allowSelection: false,
selectionType: 'series',
suppressArchiveWarning: false,
coverFunc: (s) => this.imageService.getSeriesCoverImage(s.series.id),
titleFunc: (s) => s.series.name,
titleRouteFunc: (s) => `/library/${s.series.libraryId}/series/${s.series.id}`,
metaTitleFunc: (s, wrapper) => {
const seriesWrapper = wrapper as RelatedSeriesCardEntity;
if (seriesWrapper.data.relation) {
return this.relationshipPipe.transform(seriesWrapper.data.relation);
}
return s.series.localizedName || s.series.name;
},
tooltipFunc: (s) => s.series.name,
progressFunc: (s) => ({ pages: s.series.pages, pagesRead: s.series.pagesRead }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: (s) => s.series.format,
countFunc: () => 0,
showErrorFunc: (s) => s.series.pages === 0,
ariaLabelFunc: (s) => s.series.name,
readFunc: (s) => this.readerService.readSeries(s.series, false),
clickFunc: (s) => this.router.navigate(['library', s.series.libraryId, 'series', s.series.id]),
downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe(
map(events => this.downloadService.mapToEntityType(events, s.series))
)
};
return this.mergeConfig(defaults, params?.overrides);
}
forBookmark(
params?: ConfigCardFactoryActionableParameters<PageBookmark>
): ActionableCardConfiguration<PageBookmark> {
const defaults: ActionableCardConfiguration<PageBookmark> = {
allowSelection: true,
selectionType: 'bookmark',
suppressArchiveWarning: true,
coverFunc: (s) => this.imageService.getSeriesCoverImage(s.series!.id),
titleFunc: (s) => s.series!.name,
titleRouteFunc: (s) => `/library/${s.series!.libraryId}/series/${s.seriesId}}`,
metaTitleFunc: (s, wrapper) => s.series!.name,
tooltipFunc: (s) => s.series!.name,
progressFunc: (s) => ({ pages: s.series!.pages, pagesRead: s.series!.pagesRead }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: (s) => s.series!.format,
countFunc: () => 0,
showErrorFunc: (s) => false,
ariaLabelFunc: (s) => s.series!.name,
titleRouteParamsFunc: (s) => {return { bookmarkMode: true }},
actionableFunc: (s) => this.actionFactory.getBookmarkActions(() => ({seriesId: s.series!.id, libraryId: s.series!.libraryId, seriesName: s.series!.name}), params?.shouldRenderAction),
readFunc: (s) => this.router.navigate(['library', s.series!.libraryId, 'series', s.seriesId, 'manga', s.chapterId], {queryParams: {incognitoMode: false, bookmarkMode: true}}),
clickFunc: (s) => this.router.navigate(['library', s.series!.libraryId, 'series', s.seriesId, 'manga', s.chapterId], {queryParams: {incognitoMode: false, bookmarkMode: true}}),
downloadObservableFunc: (s) => this.downloadService.activeDownloads$.pipe(
map(events => this.downloadService.mapToEntityType(events, s))
)
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Creates configuration for Chapter cards
*/
forChapter(params: ConfigCardFactoryChapterVolumeParameters<Chapter>): ActionableCardConfiguration<Chapter> {
const defaults: ActionableCardConfiguration<Chapter> = {
allowSelection: false,
selectionType: 'chapter',
suppressArchiveWarning: false,
coverFunc: (c) => this.imageService.getChapterCoverImage(c.id),
titleFunc: (c) => c.titleName || c.title || c.range,
titleRouteFunc: (c) => `/library/${params.libraryId}/series/${params.seriesId}/chapter/${c.id}`,
metaTitleFunc: (c, wrapper) => {
if (c.isSpecial) {
return c.title || c.range;
}
return c.titleName || '';
},
tooltipFunc: (c) => c.titleName || c.title || (c.range === (LooseLeafOrDefaultNumber + '') ? '' : c.range),
progressFunc: (c) => ({ pages: c.pages, pagesRead: c.pagesRead }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: () => null,
countFunc: (c) => c.files?.length > 1 && c.files[0].format !== MangaFormat.IMAGE ? c.files.length : 0,
showErrorFunc: (c) => {
const wrapper = params?.overrides as unknown as ChapterCardEntity;
return c.pages === 0 && !wrapper?.suppressArchiveWarning;
},
ariaLabelFunc: (c) => c.titleName || c.title || (c.range === (LooseLeafOrDefaultNumber + '') ? '' : c.range),
actionableFunc: (c) => this.actionFactory.getChapterActions(params.seriesId, params.libraryId, params.libraryType, params?.shouldRenderAction),
readFunc: (c) => this.readerService.readChapter(params.libraryId, params.seriesId, c, false),
clickFunc: (c) => this.router.navigate(['library', params.libraryId, 'series', params.seriesId, 'chapter', c.id]),
downloadObservableFunc: (c) => this.downloadService.activeDownloads$.pipe(
map(events => this.downloadService.mapToEntityType(events, c))
),
progressUpdateStrategy: {
getMatchCriteria: (c) => ({ chapterId: c.id }),
applyUpdate: (c, event) => ({
...c,
pagesRead: event.pagesRead
})
}
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Creates configuration for Volume cards
*/
forVolume(
params: ConfigCardFactoryChapterVolumeParameters<Volume>
): ActionableCardConfiguration<Volume> {
const defaults: ActionableCardConfiguration<Volume> = {
allowSelection: true,
selectionType: 'volume',
suppressArchiveWarning: false,
coverFunc: (v) => this.imageService.getVolumeCoverImage(v.id),
titleFunc: (v) => v.name,
titleRouteFunc: (v) => `/library/${params.libraryId}/series/${params.seriesId}/volume/${v.id}`,
metaTitleFunc: (v) => {
if (params.libraryType === LibraryType.Images) return '';
if ([LibraryType.LightNovel || LibraryType.Book].includes(params.libraryType)) {
return v.name;
}
if (v.hasOwnProperty('chapters') && v.chapters.length > 0 && v.chapters[0].titleName) {
v.chapters[0].titleName
}
return v.name;
},
tooltipFunc: (v) => v.name,
progressFunc: (v) => ({ pages: v.pages, pagesRead: v.pagesRead }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: () => null,
// Show file count if there are duplicate files for volume, not just chapter count
countFunc: (v) => (v?.chapters || [])
.filter(c => c.minNumber === LooseLeafOrDefaultNumber)
.flatMap(c => c.files)
.length,
showErrorFunc: (v) => v.pages === 0,
ariaLabelFunc: (v) => v.name,
actionableFunc: (v) => this.actionFactory.getVolumeActions(params.seriesId, params.libraryId, params.libraryType, params?.shouldRenderAction),
readFunc: (v) => {
this.readerService.readVolume(params.libraryId, params.seriesId, v, false);
},
downloadObservableFunc: (v) => this.downloadService.activeDownloads$.pipe(
map(events => this.downloadService.mapToEntityType(events, v))
),
progressUpdateStrategy: {
getMatchCriteria: (v) => ({volumeId: v.id}),
applyUpdate: (v, event) => {
// Find and update the specific chapter
const chapterIndex = v.chapters.findIndex(c => c.id === event.chapterId);
if (chapterIndex === -1) return v; // Chapter not in this volume
const updatedChapters = [...v.chapters];
updatedChapters[chapterIndex] = {
...updatedChapters[chapterIndex],
pagesRead: event.pagesRead
};
return {
...v,
chapters: updatedChapters,
pagesRead: updatedChapters.reduce((sum, c) => sum + c.pagesRead, 0)
};
}
}
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Creates configuration for Collection cards
*/
forCollection(params?: ConfigCardFactoryActionableParameters<UserCollection>): ActionableCardConfiguration<UserCollection> {
const defaults: ActionableCardConfiguration<UserCollection> = {
allowSelection: true,
selectionType: 'collection',
suppressArchiveWarning: true,
coverFunc: (c) => this.imageService.getCollectionCoverImage(c.id),
titleFunc: (c) => c.title,
titleRouteFunc: (c) => `/collections/${c.id}`,
metaTitleFunc: (c) => '',
tooltipFunc: (c) => c.title,
progressFunc: () => ({ pages: 0, pagesRead: 0 }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: () => null,
countFunc: (c) => c.itemCount,
showErrorFunc: () => false,
ariaLabelFunc: (c) => c.title,
actionableFunc: (c) => this.actionFactory.getCollectionTagActions(params?.shouldRenderAction),
readFunc: () => {},
clickFunc: (c) => this.router.navigate(['collections', c.id]),
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Creates configuration for ReadingList cards
*/
forReadingList(params?: ConfigCardFactoryActionableParameters<ReadingList>): ActionableCardConfiguration<ReadingList> {
const defaults: ActionableCardConfiguration<ReadingList> = {
allowSelection: true,
selectionType: 'readingList',
suppressArchiveWarning: true,
coverFunc: (r) => this.imageService.getReadingListCoverImage(r.id),
titleFunc: (r) => r.title,
titleRouteFunc: (r) => `/lists/${r.id}`,
metaTitleFunc: (r) => r.summary || '',
tooltipFunc: (r) => r.title,
progressFunc: () => ({ pages: 0, pagesRead: 0 }),
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
formatBadgeFunc: () => null,
countFunc: (r) => r.itemCount,
showErrorFunc: () => false,
ariaLabelFunc: (r) => r.title,
actionableFunc: (r) => this.actionFactory.getReadingListActions(params?.shouldRenderAction),
readFunc: () => {},
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Creates configuration for Recently cards
*/
forRecentlyUpdated(
params?: ConfigCardFactoryBaseParameters<SeriesGroup>
): BaseCardConfiguration<SeriesGroup> {
const defaults: BaseCardConfiguration<SeriesGroup> = {
allowSelection: false,
selectionType: 'series',
suppressArchiveWarning: false,
coverFunc: (s) => this.imageService.getSeriesCoverImage(s.seriesId),
titleFunc: (s) => s.seriesName,
titleRouteFunc: (s) => `/library/${s.libraryId}/series/${s.seriesId}`,
metaTitleFunc: (s, wrapper) => '',
titleTemplate: params?.titleRef,
metaTitleTemplate: params?.metaTitleRef,
tooltipFunc: (s) => s.seriesName,
progressFunc: (s) => ({ pages: 0, pagesRead: 0 }),
formatBadgeFunc: (s) => s.format,
countFunc: (s) => s.count,
showErrorFunc: (s) => false,
ariaLabelFunc: (s) => s.seriesName,
readFunc: (s) => null,
clickFunc: (s) => this.router.navigate(['library', s.libraryId, 'series', s.seriesId]),
};
return this.mergeConfig(defaults, params?.overrides);
}
/**
* Merges default configuration with overrides.
* Overrides take precedence.
*/
private mergeConfig<C extends BaseCardConfiguration<any>>(
defaults: C,
overrides?: Partial<C>
): C {
if (!overrides) return defaults;
return { ...defaults, ...overrides } as C;
}
}
@@ -4,9 +4,10 @@ import {environment} from 'src/environments/environment';
import {UserCollection} from '../_models/collection-tag';
import {TextResonse} from '../_types/text-response';
import {MalStack} from "../_models/collection/mal-stack";
import {Action, ActionItem} from "./action-factory.service";
import {User} from "../_models/user/user";
import {AccountService} from "./account.service";
import {AccountService, Role} from "./account.service";
import {ActionItem} from "../_models/actionables/action-item";
import {Action} from "../_models/actionables/action";
@Injectable({
providedIn: 'root'
@@ -18,6 +19,10 @@ export class CollectionTagService {
baseUrl = environment.apiUrl;
getCollectionById(collectionId: number) {
return this.httpClient.get<UserCollection>(this.baseUrl + 'collection/single?collectionId=' + collectionId);
}
allCollections(ownedOnly = false) {
return this.httpClient.get<UserCollection[]>(this.baseUrl + 'collection?ownedOnly=' + ownedOnly);
}
@@ -27,7 +32,7 @@ export class CollectionTagService {
}
updateTag(tag: UserCollection) {
return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse);
return this.httpClient.post<UserCollection>(this.baseUrl + 'collection/update', tag);
}
promoteMultipleCollections(tags: Array<number>, promoted: boolean) {
@@ -59,7 +64,7 @@ export class CollectionTagService {
}
actionListFilter(action: ActionItem<UserCollection>, user: User) {
const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user);
const canPromote = this.accountService.hasRole(user, Role.Admin) || this.accountService.hasRole(user, Role.Promote);
const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;
if (isPromotionAction) return canPromote;
@@ -176,7 +176,7 @@ export class ColorscapeService {
* @param complementaryColor
*/
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
if (this.accountService.currentUserSignal()?.preferences?.colorScapeEnabled === false || this.getCssVariable('--colorscape-enabled') === 'false') {
if (this.accountService.currentUser()?.preferences?.colorScapeEnabled === false || this.getCssVariable('--colorscape-enabled') === 'false') {
return;
}
+25 -19
View File
@@ -1,6 +1,6 @@
import {HttpClient} from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {ReplaySubject, shareReplay, tap} from 'rxjs';
import {DestroyRef, inject, Injectable, signal} from '@angular/core';
import {EMPTY, switchMap, tap} from 'rxjs';
import {environment} from 'src/environments/environment';
import {Device} from '../_models/device/device';
import {DevicePlatform} from '../_models/device/device-platform';
@@ -8,35 +8,41 @@ import {TextResonse} from '../_types/text-response';
import {AccountService} from './account.service';
import {ClientDevice} from "../_models/client-device";
import {map} from "rxjs/operators";
import {toSignal} from "@angular/core/rxjs-interop";
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
})
export class DeviceService {
private httpClient = inject(HttpClient);
private accountService = inject(AccountService);
private readonly httpClient = inject(HttpClient);
private readonly accountService = inject(AccountService);
private readonly destroyRef = inject(DestroyRef);
baseUrl = environment.apiUrl;
readonly baseUrl = environment.apiUrl;
private readonly devicesSource: ReplaySubject<Device[]> = new ReplaySubject<Device[]>(1);
public readonly devices$ = this.devicesSource.asObservable().pipe(shareReplay());
public readonly devicesSignal = toSignal(this.devices$, { initialValue: [] });
private readonly _devices = signal<Device[]>([]);
public readonly devices = this._devices.asReadonly();
public readonly devices$ = toObservable(this.devices);
constructor() {
// Ensure we are authenticated before we make an authenticated api call.
this.accountService.currentUser$.subscribe(user => {
if (!user) {
this.devicesSource.next([]);
return;
}
this.httpClient.get<Device[]>(this.baseUrl + 'device', {}).subscribe(data => {
this.devicesSource.next(data);
});
// Ensure we are authenticated before we make an authenticated api call.
toObservable(this.accountService.currentUser).pipe(
switchMap(user => {
if (!user) {
this._devices.set([]);
return EMPTY;
}
return this.httpClient.get<Device[]>(this.baseUrl + 'device');
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(data => {
if (data) this._devices.set([...data])
});
}
@@ -54,7 +60,7 @@ export class DeviceService {
getEmailDevices() {
return this.httpClient.get<Device[]>(this.baseUrl + 'device', {}).pipe(tap(data => {
this.devicesSource.next(data);
this._devices.set([...data]);
}));
}
+15 -24
View File
@@ -1,9 +1,8 @@
import {DestroyRef, inject, Injectable} from '@angular/core';
import {computed, DestroyRef, inject, Injectable} from '@angular/core';
import {environment} from 'src/environments/environment';
import {ThemeService} from './theme.service';
import {AccountService} from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ImageOnlyName} from "../_models/user/auth-key";
@Injectable({
providedIn: 'root'
@@ -14,8 +13,8 @@ export class ImageService {
private readonly destroyRef = inject(DestroyRef);
baseUrl = environment.apiUrl;
apiKey: string = '';
encodedKey: string = '';
apiKey = this.accountService.currentUserImageAuthKey;
encodedKey = computed(() => encodeURIComponent(this.apiKey()!));
public placeholderImage = 'assets/images/image-placeholder.dark-min.png';
public errorImage = 'assets/images/error-placeholder2.dark-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
@@ -37,14 +36,6 @@ export class ImageService {
this.noPersonImage = 'assets/images/error-person-missing.min.png';
}
});
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user) {
// Get the image-only key from the auth keys
this.apiKey = user.authKeys.filter(k => k.name === ImageOnlyName)[0].key;
this.encodedKey = encodeURIComponent(this.apiKey);
}
});
}
/**
@@ -60,51 +51,51 @@ export class ImageService {
}
getPersonImage(personId: number) {
return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey()}`;
}
getUserCoverImage(userId: number) {
return `${this.baseUrl}image/user-cover?userId=${userId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/user-cover?userId=${userId}&apiKey=${this.encodedKey()}`;
}
getLibraryCoverImage(libraryId: number) {
return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey()}`;
}
getVolumeCoverImage(volumeId: number) {
return `${this.baseUrl}image/volume-cover?volumeId=${volumeId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/volume-cover?volumeId=${volumeId}&apiKey=${this.encodedKey()}`;
}
getSeriesCoverImage(seriesId: number) {
return `${this.baseUrl}image/series-cover?seriesId=${seriesId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/series-cover?seriesId=${seriesId}&apiKey=${this.encodedKey()}`;
}
getCollectionCoverImage(collectionTagId: number) {
return `${this.baseUrl}image/collection-cover?collectionTagId=${collectionTagId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/collection-cover?collectionTagId=${collectionTagId}&apiKey=${this.encodedKey()}`;
}
getReadingListCoverImage(readingListId: number) {
return `${this.baseUrl}image/readinglist-cover?readingListId=${readingListId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/readinglist-cover?readingListId=${readingListId}&apiKey=${this.encodedKey()}`;
}
getChapterCoverImage(chapterId: number) {
return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/chapter-cover?chapterId=${chapterId}&apiKey=${this.encodedKey()}`;
}
getBookmarkedImage(chapterId: number, pageNum: number, imageOffset: number = 0) {
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}&imageOffset=${imageOffset}`;
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey()}&pageNum=${pageNum}&imageOffset=${imageOffset}`;
}
getWebLinkImage(url: string) {
return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey()}`;
}
getPublisherImage(name: string) {
return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey()}`;
}
getCoverUploadImage(filename: string) {
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey()}`;
}
updateErroredWebLinkImage(event: any) {
@@ -0,0 +1,64 @@
import {inject, Injectable} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {RouterStateSnapshot, TitleStrategy} from '@angular/router';
import {TranslocoService} from '@jsverse/transloco';
@Injectable({providedIn: 'root'})
export class KavitaTitleStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly translocoService = inject(TranslocoService);
override updateTitle(routerState: RouterStateSnapshot): void {
// 1. Check for static route title (translation key)
const routeTitle = this.buildTitle(routerState);
if (routeTitle) {
this.setFormattedTitle(routeTitle);
return;
}
// 2. Check for entity-based title from resolved data
const route = this.getDeepestRoute(routerState.root);
const titleField = route.data['titleField'];
if (titleField) {
const titleProp = route.data['titleProp'] || 'name';
const titleSuffix = route.data['titleSuffix'] || '';
const entity = this.findInRouteTree(route, titleField);
if (entity?.[titleProp]) {
this.title.setTitle(`Kavita: ${entity[titleProp]}${titleSuffix}`);
return;
}
}
// 3. Fallback
this.title.setTitle('Kavita');
}
setFormattedTitle(pageTitle: string): void {
if (pageTitle.startsWith('title.')) {
pageTitle = this.translocoService.translate(pageTitle);
}
this.title.setTitle(`Kavita: ${pageTitle}`);
}
setTranslatedTitle(key: string, params: Record<string, unknown>): void {
this.title.setTitle(`Kavita: ${this.translocoService.translate(key, params)}`);
}
private getDeepestRoute(route: RouterStateSnapshot['root']): RouterStateSnapshot['root'] {
while (route.firstChild) {
route = route.firstChild;
}
return route;
}
private findInRouteTree(route: RouterStateSnapshot['root'], field: string): any {
let current: RouterStateSnapshot['root'] | null = route;
while (current) {
if (current.data[field]) {
return current.data[field];
}
current = current.parent;
}
return null;
}
}
+1 -1
View File
@@ -202,7 +202,7 @@ export class KeyBindService {
* @private
*/
private readonly customKeyBinds = computed(() => {
const customKeyBinds = this.accountService.currentUserSignal()?.preferences.customKeyBinds ?? {};
const customKeyBinds = this.accountService.currentUser()?.preferences.customKeyBinds ?? {};
return Object.fromEntries(Object.entries(customKeyBinds).filter(([target, _]) => {
return DefaultKeyBinds[target as KeyBindTarget] !== undefined; // Filter out unused or old targets
}))
+7 -3
View File
@@ -1,5 +1,5 @@
import {HttpClient} from '@angular/common/http';
import { DestroyRef, Injectable, inject } from '@angular/core';
import {DestroyRef, inject, Injectable} from '@angular/core';
import {of} from 'rxjs';
import {filter, map, tap} from 'rxjs/operators';
import {environment} from 'src/environments/environment';
@@ -85,6 +85,10 @@ export class LibraryService {
return this.httpClient.get<JumpKey[]>(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId);
}
/**
* Admin-only
* @param libraryId
*/
getLibrary(libraryId: number) {
return this.httpClient.get<Library>(this.baseUrl + 'library?libraryId=' + libraryId);
}
@@ -122,7 +126,7 @@ export class LibraryService {
}
create(model: {name: string, type: number, folders: string[]}) {
return this.httpClient.post(this.baseUrl + 'library/create', model);
return this.httpClient.post<Library>(this.baseUrl + 'library/create', model);
}
delete(libraryId: number) {
@@ -137,7 +141,7 @@ export class LibraryService {
}
update(model: {name: string, folders: string[], id: number}) {
return this.httpClient.post(this.baseUrl + 'library/update', model);
return this.httpClient.post<Library>(this.baseUrl + 'library/update', model);
}
getLibraryType(libraryId: number) {
+3 -3
View File
@@ -55,7 +55,7 @@ export class MetadataService {
private readonly seriesService = inject(SeriesService)
private readonly highlightSlots = computed(() => {
return this.accountService.currentUserSignal()?.preferences?.bookReaderHighlightSlots ?? [];
return this.accountService.currentUser()?.preferences?.bookReaderHighlightSlots ?? [];
});
baseUrl = environment.apiUrl;
@@ -180,9 +180,9 @@ export class MetadataService {
createDefaultFilterStatement(entityType: ValidFilterEntity) {
switch (entityType) {
case "annotation":
const userId = this.accountService.currentUserSignal()?.id;
const userId = this.accountService.currentUser()?.id;
if (userId) {
return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUserSignal()!.id}`);
return this.createFilterStatement(AnnotationsFilterField.Owner, FilterComparison.Equal, `${this.accountService.currentUser()!.id}`);
}
return this.createFilterStatement(AnnotationsFilterField.Owner);
case 'series':
+13 -15
View File
@@ -1,6 +1,9 @@
import {inject, Injectable, Type} from '@angular/core';
import {ComponentRef, inject, Injectable, Type} from '@angular/core';
import {NgbModal, NgbModalOptions, NgbModalRef} from '@ng-bootstrap/ng-bootstrap';
export interface TypedModalRef<C> extends NgbModalRef {
setInput<K extends string>(key: K, value: unknown): void;
}
@Injectable({
providedIn: 'root'
@@ -9,21 +12,16 @@ export class ModalService {
private modal = inject(NgbModal);
open<T>(content: Type<T>, options?: NgbModalOptions): [NgbModalRef, T] {
const modal = this.modal.open(content, options);
return [modal, modal.componentInstance as T]
}
/** * TODO: This is a hack to get the ComponentRef because NgbModalRef does not expose it.
* See https://github.com/ng-bootstrap/ng-bootstrap/issues/4688 */
open<C>(content: Type<C>, options?: NgbModalOptions): TypedModalRef<C> {
const ref = this.modal.open(content, options) as TypedModalRef<C>;
hasOpenModals() {
return this.modal.hasOpenModals()
}
ref.setInput = (key: string, value: unknown) => {
const componentRef: ComponentRef<C> = (ref as any)['_contentRef'].componentRef;
componentRef.setInput(key, value);
};
get activeInstances() {
return this.modal.activeInstances
return ref;
}
dismissAll(reason?: any) {
this.modal.dismissAll(reason);
}
}
+20 -10
View File
@@ -1,6 +1,15 @@
import {DOCUMENT} from '@angular/common';
import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2, signal} from '@angular/core';
import {filter, take} from 'rxjs';
import {
DestroyRef,
effect,
inject,
Injectable,
Renderer2,
RendererFactory2,
RendererStyleFlags2,
signal
} from '@angular/core';
import {filter} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
@@ -10,7 +19,7 @@ import {map} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
import {WikiLink} from "../_models/wiki";
import {AuthGuard} from "../_guards/auth.guard";
import {AUTH_URL_KEY} from "../_guards/auth.guard";
/**
* NavItem used to construct the dropdown or NavLinkModal on mobile
@@ -120,15 +129,16 @@ export class NavService {
constructor() {
const rendererFactory = inject(RendererFactory2);
this.renderer = rendererFactory.createRenderer(null, null);
// To avoid flashing, let's check if we are authenticated before we show
this.accountService.currentUser$.pipe(take(1)).subscribe(u => {
if (u) {
effect(() => {
const user = this.accountService.currentUser();
if (user) {
this.showNavBar();
}
});
})
const sideNavState = (localStorage.getItem(this.localStorageSideNavKey) === 'true') || false;
this.sideNavCollapsed.set(sideNavState);
@@ -204,12 +214,12 @@ export class NavService {
this.showSideNav();
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem(AuthGuard.urlKey);
const pageResume = localStorage.getItem(AUTH_URL_KEY);
if (pageResume && pageResume !== '/login') {
localStorage.setItem(AuthGuard.urlKey, '');
localStorage.setItem(AUTH_URL_KEY, '');
this.router.navigateByUrl(pageResume);
} else {
localStorage.setItem(AuthGuard.urlKey, '');
localStorage.setItem(AUTH_URL_KEY, '');
this.router.navigateByUrl('/home');
}
}
+9 -11
View File
@@ -672,24 +672,22 @@ export class ReaderService {
).catch(err => console.error(err)));
}
private handlePrompt(prompt: RereadPrompt, incognitoMode: boolean) {
private handlePrompt<T>(prompt: RereadPrompt, incognitoMode: boolean) {
if (incognitoMode) return of({prompt: prompt, result: RereadPromptResult.ReadIncognito});
if (!prompt.shouldPrompt) return of({prompt: prompt, result: RereadPromptResult.Continue});
const [modal, component] = this.modalService.open(ListSelectModalComponent, {
centered: true,
});
const ref = this.modalService.open<ListSelectModalComponent<T>>(ListSelectModalComponent);
component.showFooter.set(false);
component.title.set(translate('reread-modal.title'));
ref.componentInstance.showFooter.set(false);
ref.componentInstance.title.set(translate('reread-modal.title'));
if (prompt.timePrompt) {
component.description.set(translate('reread-modal.description-time-passed',
ref.componentInstance.description.set(translate('reread-modal.description-time-passed',
{ days: prompt.daysSinceLastRead, name: prompt.chapterOnReread.label }));
} else {
component.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label }));
ref.componentInstance.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label }));
}
const options = [
@@ -703,10 +701,10 @@ export class ReaderService {
options.push({label: translate('reread-modal.cancel'), value: RereadPromptResult.Cancel});
component.inputItems.set(options);
ref.setInput('inputItems', options);
return modal.closed.pipe(
takeUntil(modal.dismissed),
return ref.closed.pipe(
takeUntil(ref.dismissed),
take(1),
map(res => ({prompt: prompt, result: res as RereadPromptResult})),
catchError(() => of({prompt: prompt, result: RereadPromptResult.Cancel}))
@@ -1,5 +1,5 @@
import {HttpClient, HttpParams} from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import {inject, Injectable} from '@angular/core';
import {map} from 'rxjs/operators';
import {environment} from 'src/environments/environment';
import {UtilityService} from '../shared/_services/utility.service';
@@ -8,7 +8,8 @@ import {PaginatedResult} from '../_models/pagination';
import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list';
import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary';
import {TextResonse} from '../_types/text-response';
import {Action, ActionItem} from './action-factory.service';
import {ActionItem} from "../_models/actionables/action-item";
import {Action} from "../_models/actionables/action";
@Injectable({
providedIn: 'root'
@@ -98,9 +99,6 @@ export class ReadingListService {
if (isPromotionAction) return canPromote;
return true;
// if (readingList?.promoted && !isAdmin) return false;
// return true;
}
nameExists(name: string) {
@@ -1,10 +1,10 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map } from 'rxjs';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import {HttpClient, HttpParams} from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {map, Observable} from 'rxjs';
import {environment} from 'src/environments/environment';
import {UtilityService} from '../shared/_services/utility.service';
import {PaginatedResult} from '../_models/pagination';
import {Series} from '../_models/series';
@Injectable({
providedIn: 'root'
@@ -48,6 +48,6 @@ export class RecommendationService {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'recommended/more-in?libraryId=' + libraryId + '&genreId=' + genreId, {observe: 'response', params})
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
.pipe(map(response => this.utilityService.createPaginatedResult(response))) as Observable<PaginatedResult<Series[]>>;
}
}
+5 -3
View File
@@ -100,7 +100,7 @@ export class SeriesService {
}
updateSeries(model: any) {
return this.httpClient.post(this.baseUrl + 'series/update', model);
return this.httpClient.post<Series>(this.baseUrl + 'series/update', model);
}
markRead(seriesId: number) {
@@ -143,7 +143,8 @@ export class SeriesService {
return this.httpClient.post<Series[]>(url, data, {observe: 'response', params}).pipe(
map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}));
})
);
}
isWantToRead(seriesId: number) {
@@ -166,7 +167,8 @@ export class SeriesService {
return this.httpClient.post<Series[]>(url, data, {observe: 'response', params}).pipe(
map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}));
})
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ export class ThemeService {
});
effect(() => {
const user = this.accountService.currentUserSignal();
const user = this.accountService.currentUser();
if (user?.preferences && user?.preferences.theme) {
this.setTheme(user.preferences.theme.name);
} else {
+187 -160
View File
@@ -1,40 +1,48 @@
import {inject, Injectable, OnDestroy} from '@angular/core';
import {DestroyRef, inject, Injectable} from '@angular/core';
import {interval, Subscription, switchMap} from 'rxjs';
import {ServerService} from "./server.service";
import {AccountService} from "./account.service";
import {filter, take} from "rxjs/operators";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component";
import {filter, map, take, tap} from "rxjs/operators";
import {Router} from "@angular/router";
import {OpdsName} from "../_models/user/auth-key";
import {
VersionUpdateModalComponent
} from "../announcements/_components/version-update-modal/version-update-modal.component";
import {versionNotifyModal, versionRefreshModal} from "../_models/modal/modal-options";
import {UpdateVersionEvent} from "../_models/events/update-version-event";
import {ModalService} from "./modal.service";
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
})
export class VersionService implements OnDestroy{
export class VersionService {
private readonly serverService = inject(ServerService);
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
private readonly modalService = inject(ModalService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
public static readonly SERVER_VERSION_KEY = 'kavita--version';
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown';
public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown';
private static readonly DISMISS_KEY_PREFIX = 'kavita--update-dismiss-';
// Notification intervals
private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once)
private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds
// Check intervals
private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours
private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date"
/** Threshold: above this count shows "out of date" instead of "update available" */
private readonly OUT_OF_BAND_AMOUNT = 3;
// Routes where version update modals should not be shown
/** Backoff intervals indexed by dismiss count: [after 1st dismiss, after 2nd dismiss] */
private readonly BACKOFF_INTERVALS = [
1 * 24 * 60 * 60 * 1000, // 1 day
3 * 24 * 60 * 60 * 1000, // 3 days
7 * 24 * 60 * 60 * 1000, // 1 week
14 * 24 * 60 * 60 * 1000, // 2 weeks
30 * 24 * 60 * 60 * 1000, // 1 month
];
/** After this many dismissals, Kavita will stop pestering the user */
private readonly MAX_DISMISSALS = 5;
/** Routes where version update modals should not be shown */
private readonly EXCLUDED_ROUTES = [
'/manga/',
'/book/',
@@ -44,208 +52,227 @@ export class VersionService implements OnDestroy{
private versionCheckSubscription?: Subscription;
private outOfDateCheckSubscription?: Subscription;
private modalOpen = false;
/** Version fetched on initial page load — used to detect mid-session server updates */
private loadedVersion: string | null = null;
/** Tracks which version the currently-open modal is for, so we can record dismissal on close */
private activeModalVersion: string | null = null;
constructor() {
this.startInitialVersionCheck();
this.startVersionCheck();
this.startOutOfDateCheck();
}
ngOnDestroy() {
this.versionCheckSubscription?.unsubscribe();
this.outOfDateCheckSubscription?.unsubscribe();
}
/**
* Initial version check to ensure localStorage is populated on first load
*/
private startInitialVersionCheck(): void {
this.accountService.currentUser$
.pipe(
filter(user => !!user),
take(1),
switchMap(user => this.serverService.getVersion(user!.authKeys.filter(k => k.name === OpdsName)[0].key))
)
.subscribe(serverVersion => {
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
// Always update localStorage on first load
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion);
});
toObservable(this.accountService.currentUserGenericApiKey).pipe(
filter((key): key is string => !!key),
take(1),
switchMap(key => this.serverService.getVersion(key))
).subscribe(serverVersion => {
this.loadedVersion = serverVersion;
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
this.cleanupOldDismissals(serverVersion);
console.log('Initial version check - Server version:', serverVersion);
});
}
/**
* Periodic check for server version to detect client refreshes and new updates
*/
private startVersionCheck(): void {
console.log('Starting version checker');
this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL)
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(user => !!user && !this.modalOpen),
switchMap(user => this.serverService.getVersion(user!.authKeys.filter(k => k.name === OpdsName)[0].key)),
map(() => this.accountService.currentUserGenericApiKey()),
filter((key): key is string => !!key && !this.modalOpen),
switchMap(key => this.serverService.getVersion(key)),
filter(update => !!update),
).subscribe(version => this.handleVersionUpdate(version));
}
/**
* Checks if the server is out of date compared to the latest release
*/
private startOutOfDateCheck() {
console.log('Starting out-of-date checker');
this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL)
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen),
switchMap(_ => this.serverService.checkHowOutOfDate(true)),
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT),
)
.subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate));
tap(serverVersion => this.handleVersionCheck(serverVersion)),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
/**
* Checks if the current route is in the excluded routes list
*/
private isExcludedRoute(): boolean {
isExcludedRoute(): boolean {
const currentUrl = this.router.url;
return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route));
}
/**
* Handles the version check response to determine if client refresh or new update notification is needed
* Given a server version string, determines whether to show a refresh modal
* (server updated mid-session) or check for available updates.
*/
private handleVersionUpdate(serverVersion: string) {
if (this.modalOpen) return;
handleVersionCheck(serverVersion: string): void {
if (this.modalOpen || this.isExcludedRoute()) return;
// Validate if we are on a reader route and if so, suppress
if (this.isExcludedRoute()) {
console.log('Version update blocked due to user reading');
return;
}
const isNewServerVersion = this.loadedVersion !== null && this.loadedVersion !== serverVersion;
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);
const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion;
// Case 1: Client Refresh needed (server has updated since last client load)
if (isNewServerVersion) {
this.showClientRefreshNotification(serverVersion);
// Server was updated mid-session — don't update loadedVersion so the
// refresh prompt persists until the user actually refreshes.
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
this.serverService.getChangelog(1).subscribe(changelog => {
this.showRefreshModal(changelog[0]);
localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString());
});
} else {
this.handleUpdateCheck();
}
// Case 2: Check for new updates (for server admin)
else {
this.checkForNewUpdates();
}
// Always update the cached version
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
}
/**
* Shows a notification that client refresh is needed due to server update
* Checks if the admin should be notified of a new update or that the server is significantly out of date.
* Single API call to checkHowOutOfDate determines which modal (if any) to show.
*/
private showClientRefreshNotification(newVersion: string): void {
this.pauseChecks();
// Client refresh notifications should always show (once)
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, {
size: 'lg',
keyboard: false,
backdrop: 'static' // Prevent closing by clicking outside
});
ref.componentInstance.version = newVersion;
ref.componentInstance.update = changelog[0];
ref.componentInstance.requiresRefresh = true;
// Update the last shown timestamp
localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString());
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
handleUpdateCheck(): void {
if (!this.accountService.hasAdminRole()) return;
this.serverService.checkHowOutOfDate().pipe(
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0),
).subscribe(versionsOutOfDate => {
if (versionsOutOfDate > this.OUT_OF_BAND_AMOUNT) {
this.handleOutOfDate(versionsOutOfDate);
} else {
this.handleUpdateAvailable(versionsOutOfDate);
}
});
}
/**
* Checks for new server updates and shows notification if appropriate
* Given a versionsOutOfDate count (13), fetches changelog and shows the
* update-available modal. Backoff is applied in showUpdateModal.
*/
private checkForNewUpdates(): void {
this.accountService.currentUser$
.pipe(
take(1),
filter(user => user !== undefined && this.accountService.hasAdminRole(user)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT)
)
.subscribe(versionsOutOfDate => {
const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0');
const currentTime = Date.now();
// Show notification if it hasn't been shown in the last week
if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) {
this.pauseChecks();
this.modalOpen = true;
this.serverService.getChangelog(1).subscribe(changelog => {
const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' });
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
ref.componentInstance.update = changelog[0];
ref.componentInstance.requiresRefresh = false;
// Update the last shown timestamp
localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString());
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
});
}
});
handleUpdateAvailable(versionsOutOfDate: number): void {
this.serverService.getChangelog(1).subscribe(changelog => {
this.showUpdateAvailableModal(changelog[0], versionsOutOfDate);
});
}
/**
* Handles the notification for servers that are significantly out of date
* Given a versionsOutOfDate count (4+), shows the out-of-date modal.
* Backoff is applied in showUpdateModal.
*/
private handleOutOfDateNotification(versionsOutOfDate: number): void {
const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0');
const currentTime = Date.now();
handleOutOfDate(versionsOutOfDate: number): void {
this.showOutOfDateModal(versionsOutOfDate);
}
// Show notification if it hasn't been shown in the last month
if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) {
this.pauseChecks();
this.modalOpen = true;
/**
* Single entry point for opening version update modals.
* Prevents stacking only one modal can be open at a time.
* For non-refresh modes, applies per-version backoff before opening.
*/
showUpdateModal(mode: 'refresh' | 'update-available' | 'out-of-date', data: { update?: UpdateVersionEvent | null, versionsOutOfDate?: number } = {}, force: boolean = false): void {
if (this.modalOpen) return;
const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' });
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
// Per-version backoff for dismissible modes (skipped for refresh and user-initiated actions)
if (mode !== 'refresh' && !force) {
const backoffVersion = this.getBackoffVersion(mode, data);
if (backoffVersion && !this.shouldShowNotification(backoffVersion)) return;
this.activeModalVersion = backoffVersion;
}
// Update the last shown timestamp
localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString());
this.pauseChecks();
this.modalOpen = true;
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
const options = mode === 'refresh' ? versionRefreshModal() : versionNotifyModal();
const ref = this.modalService.open(VersionUpdateModalComponent, options);
ref.setInput('mode', mode);
if (data?.update != null) ref.setInput('update', data.update);
if (data?.versionsOutOfDate != null) ref.setInput('versionsOutOfDate', data.versionsOutOfDate);
ref.closed.subscribe(_ => this.onModalClosed());
ref.dismissed.subscribe(_ => this.onModalClosed());
}
/**
* Shows the refresh-required modal. The server was updated mid-session
* and the browser needs to reload to pick up new client assets.
*/
showRefreshModal(update: UpdateVersionEvent): void {
this.showUpdateModal('refresh', { update });
}
/**
* Shows the update-available modal. A newer version exists that the admin can download.
*/
showUpdateAvailableModal(update: UpdateVersionEvent, versionsOutOfDate: number = 1): void {
this.showUpdateModal('update-available', { update, versionsOutOfDate });
}
/**
* Shows the out-of-date warning modal. The server is significantly behind the latest release.
*/
showOutOfDateModal(versionsOutOfDate: number): void {
this.showUpdateModal('out-of-date', { versionsOutOfDate });
}
/**
* Determines the version string used for backoff tracking.
* update-available: the version available to update to.
* out-of-date: the current server version (resets when user updates).
*/
private getBackoffVersion(mode: string, data: { update?: UpdateVersionEvent | null }): string | null {
if (mode === 'update-available') return data.update?.updateVersion ?? null;
if (mode === 'out-of-date') return this.loadedVersion;
return null;
}
/**
* Checks per-version dismissal history to determine if we should show the notification.
* Returns false if the user has dismissed enough times or too recently.
*/
shouldShowNotification(targetVersion: string): boolean {
const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion);
if (!raw) return true;
const { count, lastDismissed } = JSON.parse(raw) as { count: number; lastDismissed: number };
if (count >= this.MAX_DISMISSALS) return false;
const interval = this.BACKOFF_INTERVALS[Math.min(count - 1, this.BACKOFF_INTERVALS.length - 1)];
return Date.now() - lastDismissed >= interval;
}
/**
* Records a dismissal for the given version, incrementing the count and updating the timestamp.
*/
recordDismissal(targetVersion: string): void {
const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion);
const current = raw ? JSON.parse(raw) as { count: number } : { count: 0 };
localStorage.setItem(VersionService.DISMISS_KEY_PREFIX + targetVersion, JSON.stringify({
count: current.count + 1,
lastDismissed: Date.now(),
}));
}
/**
* Removes dismiss keys for versions other than the current server version.
* Prevents stale backoff data from carrying over after an update.
*/
private cleanupOldDismissals(currentVersion: string): void {
const keepKey = VersionService.DISMISS_KEY_PREFIX + currentVersion;
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && key.startsWith(VersionService.DISMISS_KEY_PREFIX) && key !== keepKey) {
localStorage.removeItem(key);
}
}
}
/**
* Pauses all version checks while modals are open
*/
private pauseChecks(): void {
this.versionCheckSubscription?.unsubscribe();
this.outOfDateCheckSubscription?.unsubscribe();
}
/**
* Resumes all checks when modals are closed
*/
private onModalClosed(): void {
if (this.activeModalVersion) {
this.recordDismissal(this.activeModalVersion);
this.activeModalVersion = null;
}
this.modalOpen = false;
this.startVersionCheck();
this.startOutOfDateCheck();
}
}
@@ -1,29 +0,0 @@
<ng-container *transloco="let t; prefix: 'actionable'">
<div class="modal-header">
<h4 class="modal-title">
{{t('title')}}
</h4>
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div>
<div class="modal-body scrollable-modal">
@if (currentLevel.length > 0) {
<button class="btn btn-secondary w-100 mb-3 text-start" (click)="handleBack()">
← {{t('back-to', {action: currentLevel[currentLevel.length - 1]})}}
</button>
}
<div class="d-grid gap-2">
@for (action of currentItems; track action.title) {
@if (willRenderAction(action, user!)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
(click)="handleItemClick(action)">
{{action.title}}
@if (action.children.length > 0 || action.dynamicList) {
<span class="ms-1"></span>
}
</button>
}
}
</div>
</div>
</ng-container>
@@ -1,122 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {Action, ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {AccountService} from "../../_services/account.service";
import {tap} from "rxjs";
import {User} from "../../_models/user/user";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-actionable-modal',
imports: [
TranslocoDirective
],
templateUrl: './actionable-modal.component.html',
styleUrl: './actionable-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActionableModalComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly modal = inject(NgbActiveModal);
protected readonly accountService = inject(AccountService);
protected readonly cdRef = inject(ChangeDetectorRef);
protected readonly destroyRef = inject(DestroyRef);
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>, user: User) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
@Output() actionPerformed = new EventEmitter<ActionItem<any>>();
currentLevel: string[] = [];
currentItems: ActionItem<any>[] = [];
user!: User | undefined;
ngOnInit() {
// Copy as the list may be shared between entities
const actionItems = this.actions.map(action => this.utilityService.copyActionItem(action));
// On Mobile, surface download
const otherActionIndex = actionItems.findIndex(i => i.action === Action.Submenu && i.title === 'others')
if (otherActionIndex >= 0) {
const downloadActionIndex = actionItems[otherActionIndex].children.findIndex(a => a.action === Action.Download);
if (downloadActionIndex >= 0) {
const downloadAction = actionItems[otherActionIndex].children.splice(downloadActionIndex, 1)[0];
actionItems.push(downloadAction);
// Check if Other has any other children, else remove
if (actionItems[otherActionIndex].children.length === 0) {
actionItems.splice(otherActionIndex, 1);
}
}
}
this.actions = actionItems;
this.currentItems = this.translateOptions(this.actions)
this.accountService.currentUser$.pipe(tap(user => {
this.user = user;
this.cdRef.markForCheck();
}), takeUntilDestroyed(this.destroyRef)).subscribe();
}
handleItemClick(item: ActionItem<any>) {
if (item.children && item.children.length > 0) {
this.currentLevel.push(item.title);
if (item.children.length === 1 && item.children[0].dynamicList) {
item.children[0].dynamicList.subscribe(dynamicItems => {
this.currentItems = dynamicItems.map(di => ({
...item,
children: [], // Required as dynamic list is only one deep
title: di.title,
_extra: di,
action: item.children[0].action // override action to be correct from child
}));
});
} else {
this.currentItems = this.translateOptions(item.children);
}
}
else {
this.actionPerformed.emit(item);
this.modal.close(item);
}
this.cdRef.markForCheck();
}
handleBack() {
if (this.currentLevel.length > 0) {
this.currentLevel.pop();
let items = this.actions;
for (let level of this.currentLevel) {
items = items.find(item => item.title === level)?.children || [];
}
this.currentItems = this.translateOptions(items);
this.cdRef.markForCheck();
}
}
translateOptions(opts: Array<ActionItem<any>>) {
return opts.map(a => {
return {...a, title: translate('actionable.' + a.title)};
})
}
}
@@ -47,7 +47,7 @@
</div>
<div class="progress-container">
<div class="progress" style="height: 4px;">
<div class="progress" style="height: 0.25rem;">
<div class="progress-bar bg-warning"
[style.width.%]="((activeData.startPage + activeData.pagesRead) / activeData.totalPages) * 100"
role="progressbar">
@@ -3,14 +3,14 @@
.activity-card {
background: var(--elevation-layer1-dark-solid);
border-radius: 8px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 20px;
margin-bottom: 1.25rem;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--elevation-layer10-dark);
transform: translateY(-0.125rem);
box-shadow: 0 0.25rem 0.75rem var(--elevation-layer10-dark);
}
.activity-content {
@@ -22,8 +22,8 @@
.cover-container {
position: relative;
flex-shrink: 0;
width: 200px;
height: 300px;
width: 12.5rem;
height: 18.75rem;
.series-cover {
width: 100%;
@@ -33,20 +33,20 @@
.chapter-cover {
position: absolute;
bottom: 8px;
right: 8px;
width: 60px;
height: 85px;
bottom: 0.5rem;
right: 0.5rem;
width: 3.75rem;
height: 5.3125rem;
object-fit: cover;
border: 2px solid var(--elevation-layer1-dark-solid);
border-radius: 4px;
box-shadow: 0 2px 8px var(--elevation-layer11-dark);
border: 0.125rem solid var(--elevation-layer1-dark-solid);
border-radius: 0.25rem;
box-shadow: 0 0.125rem 0.5rem var(--elevation-layer11-dark);
}
}
.activity-details {
flex: 1;
padding: 16px;
padding: 1rem;
display: flex;
flex-direction: column;
background: linear-gradient(to right, var(--elevation-layer11-dark), var(--elevation-layer1-dark-solid));
@@ -57,76 +57,76 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 8px;
gap: 0.5rem;
}
.media-info {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 0.375rem;
align-items: center;
}
.platform-badge {
background: var(--activity-card-client-platform-badge-bg-color) !important;
font-size: 11px;
padding: 4px 8px;
font-size: 0.6875rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
}
.device-badge {
background: var(--activity-card-client-device-badge-bg-color);
font-size: 11px;
padding: 4px 8px;
font-size: 0.6875rem;
padding: 0.25rem 0.5rem;
}
.session-metadata {
margin-bottom: 12px;
margin-bottom: 0.75rem;
.metadata-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
.label {
color: var(--offwhite-text-color);
text-transform: uppercase;
font-weight: 600;
min-width: 80px;
min-width: 5rem;
}
}
}
.progress-container {
margin-bottom: 12px;
margin-bottom: 0.75rem;
.progress {
background: var(--progress-bg-color);
border-radius: 2px;
border-radius: 0.125rem;
}
.progress-time {
display: flex;
justify-content: flex-end;
margin-top: 4px;
font-size: 11px;
margin-top: 0.25rem;
font-size: 0.6875rem;
color: var(--offwhite-text-color);
}
}
.title-section {
display: flex;
gap: 12px;
gap: 0.75rem;
align-items: center;
margin-bottom: 12px;
margin-bottom: 0.75rem;
margin-top: auto;
.play-icon {
font-size: 24px;
font-size: 1.5rem;
color: var(--primary-color);
flex-shrink: 0;
}
@@ -138,7 +138,7 @@
.series-title {
color: var(--primary-color);
font-size: 16px;
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -147,7 +147,7 @@
.chapter-info {
color: var(--offwhite-text-color);
font-size: 13px;
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -158,12 +158,12 @@
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 8px;
padding-top: 0.5rem;
border-top: 1px solid var(--offwhite-text-color);
.username {
color: var(--offwhite-text-color);
font-size: 13px;
font-size: 0.8125rem;
font-weight: 500;
}
}
@@ -173,21 +173,21 @@
@media (max-width: 768px) {
.activity-card {
.cover-container {
width: 120px;
height: 180px;
width: 7.5rem;
height: 11.25rem;
.chapter-cover {
width: 45px;
height: 65px;
width: 2.8125rem;
height: 4.0625rem;
}
}
.activity-details {
padding: 12px;
padding: 0.75rem;
}
.series-title {
font-size: 14px;
font-size: 0.875rem;
}
}
}
@@ -2,7 +2,7 @@
<virtual-scroller #scroll [items]="annotations()" [parentScroll]="scrollingBlock()" [childHeight]="1">
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
<div style="min-width: 200px" class="col-auto m-2">
<div style="min-width: 12.5rem" class="col-auto m-2">
<app-annotation-card
[annotation]="item"
[allowEdit]="false"
@@ -0,0 +1,27 @@
<ng-container *transloco="let t; prefix: 'actionable'">
<div class="modal-header">
<h4 class="modal-title">
{{t('title')}}
</h4>
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div>
<div class="modal-body scrollable-modal">
@if (currentLevel().length > 0) {
<button class="btn btn-secondary w-100 mb-3 text-start" (click)="handleBack()">
← {{t('back-to', {action: currentLevel()[currentLevel().length - 1]})}}
</button>
}
<div class="d-grid gap-2">
@for (action of currentItems(); track action.title) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
(click)="handleItemClick(action)">
{{action.title}}
@if (action.children.length > 0 || action.dynamicList) {
<span class="ms-1"></span>
}
</button>
}
</div>
</div>
</ng-container>
@@ -0,0 +1,129 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
input,
OnInit,
signal,
output
} from '@angular/core';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {UtilityService} from "../../../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {AccountService} from "../../../../_services/account.service";
import {Observable} from "rxjs";
import {ActionableEntity} from "../../../../_services/action-factory.service";
import {ActionItem} from "../../../../_models/actionables/action-item";
import {Action} from "../../../../_models/actionables/action";
import {ActionResult} from "../../../../_models/actionables/action-result";
@Component({
selector: 'app-actionable-modal',
imports: [
TranslocoDirective
],
templateUrl: './actionable-modal.component.html',
styleUrl: './actionable-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActionableModalComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly modal = inject(NgbActiveModal);
protected readonly accountService = inject(AccountService);
protected readonly cdRef = inject(ChangeDetectorRef);
protected readonly destroyRef = inject(DestroyRef);
entity = input<ActionableEntity>(null);
/** This assumes these are filtered actions */
filteredActions = input<ActionItem<any>[]>([]);
readonly actionPerformed = output<ActionItem<any> | ActionResult<any>>();
currentItems = signal<ActionItem<any>[]>([]);
currentLevel = signal<string[]>([]);
ngOnInit() {
// Copy as the list may be shared between entities
const actionItems = this.surfaceDownloadAction(
this.filteredActions().map(a => this.utilityService.copyActionItem(a))
);
this.currentItems.set(this.translateOptions(actionItems));
}
handleItemClick(item: ActionItem<any>) {
if (item.children?.length > 0) {
this.currentLevel.update(levels => [...levels, item.title]);
if (item.children.length === 1 && item.children[0].dynamicList) {
item.children[0].dynamicList.subscribe(dynamicItems => {
this.currentItems.set(dynamicItems.map(di => ({
...item,
children: [], // Required as dynamic list is only one deep
title: di.title,
_extra: di,
action: item.children[0].action // override action to be correct from child
})));
});
} else {
this.currentItems.set(this.translateOptions(item.children));
}
return;
}
const result = item.callback(item, this.entity());
if (result && typeof (result as any).subscribe === 'function') {
(result as Observable<ActionResult<any>>).subscribe(actionResult => {
this.actionPerformed.emit(actionResult);
this.modal.close(actionResult);
});
return;
}
this.modal.close(item);
}
handleBack() {
this.currentLevel.update(levels => {
const next = levels.slice(0, -1);
let items = this.filteredActions().map(a => this.utilityService.copyActionItem(a));
items = this.surfaceDownloadAction(items);
for (const level of next) {
items = items.find(i => i.title === level)?.children || [];
}
this.currentItems.set(this.translateOptions(items));
return next;
});
}
translateOptions(opts: Array<ActionItem<any>>) {
return opts.map(a => {
return {...a, title: translate('actionable.' + a.title)};
})
}
/**
* On mobile, pull the Download action out of the "Others" submenu to top-level.
*/
private surfaceDownloadAction(items: ActionItem<any>[]): ActionItem<any>[] {
const otherIdx = items.findIndex(i => i.action === Action.Submenu && i.title === 'others');
if (otherIdx < 0) return items;
const dlIdx = items[otherIdx].children.findIndex(a => a.action === Action.Download);
if (dlIdx < 0) return items;
const downloadAction = items[otherIdx].children.splice(dlIdx, 1)[0];
items.push(downloadAction);
if (items[otherIdx].children.length === 0) {
items.splice(otherIdx, 1);
}
return items;
}
}
@@ -1,6 +1,6 @@
<ng-container *transloco="let t; prefix: 'actionable'">
@let labelId = 'actions-' + labelBy();
@if (actions().length > 0) {
@if (filteredActions().length > 0) {
@if (breakpointService.isTabletOrBelow()) {
<button [disabled]="disabled()" class="btn {{btnClass()}}" id="{{labelId}}"
(click)="openMobileActionableMenu($event)">
@@ -15,7 +15,7 @@
<i class="fa {{iconClass()}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="{{labelId}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions() }" />
<ng-container *ngTemplateOutlet="submenu; context: { list: filteredActions() }" />
</div>
</div>
<ng-template #submenu let-list="list">
@@ -26,11 +26,11 @@
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
} @else if (willRenderAction(action, this.currentUser()!)) {
} @else {
<button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser()!)) {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="openSubmenu(action.title, subMenuHover)"
@@ -9,8 +9,8 @@
content: '';
position: absolute;
top: 0;
right: -10px;
width: 10px;
right: -0.625rem;
width: 0.625rem;
height: 100%;
background: transparent;
cursor: pointer;
@@ -1,13 +1,25 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, input, OnDestroy, Output} from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
OnDestroy,
output
} from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import {AccountService} from 'src/app/_services/account.service';
import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionableEntity} from 'src/app/_services/action-factory.service';
import {AsyncPipe, NgClass, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {ActionableModalComponent} from "./_modals/actionable-modal/actionable-modal.component";
import {User} from "../../_models/user/user";
import {BreakpointService} from "../../_services/breakpoint.service";
import {ActionItem} from "../../_models/actionables/action-item";
import {ActionResult} from "../../_models/actionables/action-result";
import {filterActionTree} from "../../../libs/action-utils";
import {ModalService} from "../../_services/modal.service";
@Component({
@@ -23,7 +35,7 @@ import {BreakpointService} from "../../_services/breakpoint.service";
export class CardActionablesComponent implements OnDestroy {
private readonly accountService = inject(AccountService);
protected readonly modalService = inject(NgbModal);
protected readonly modalService = inject(ModalService);
protected readonly breakpointService = inject(BreakpointService);
iconClass = input<string>('fa-ellipsis-v');
@@ -44,9 +56,18 @@ export class CardActionablesComponent implements OnDestroy {
/**
* This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked.
*/
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
readonly actionHandler = output<ActionResult<any>>();
currentUser = this.accountService.currentUserSignal;
filteredActions = computed(() => {
const entity = this.entity();
const user = this.accountService.currentUser();
const actions = this.actions();
if (!user || !actions.length) return [];
return filterActionTree(actions, entity, user, this.accountService);
});
currentUser = this.accountService.currentUser;
submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
@@ -62,13 +83,9 @@ export class CardActionablesComponent implements OnDestroy {
performAction(event: any, action: ActionItem<ActionableEntity>) {
this.preventEvent(event);
if (typeof action.callback === 'function') {
if (this.entity() === null) {
this.actionHandler.emit(action);
} else {
action.callback(action, this.entity());
}
}
action.callback(action, this.entity()).subscribe(actionResult => {
this.actionHandler.emit(actionResult);
});
}
/**
@@ -77,7 +94,11 @@ export class CardActionablesComponent implements OnDestroy {
* @param user
*/
willRenderAction(action: ActionItem<ActionableEntity>, user: User) {
return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity(), user);
const hasValidRole = !action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles);
const shouldRenderFuncPasses = action.shouldRender(action, this.entity(), user);
//console.log('Action: ', action, 'has valid role: ', hasValidRole, ' and should render func passes: ', shouldRenderFuncPasses);
return hasValidRole && shouldRenderFuncPasses;
}
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
@@ -119,19 +140,6 @@ export class CardActionablesComponent implements OnDestroy {
}
}
hasRenderableChildren(action: ActionItem<ActionableEntity>, user: User): boolean {
if (!action.children || action.children.length === 0) return false;
for (const child of action.children) {
const dynamicList = child.dynamicList;
if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded
if (this.willRenderAction(child, user)) return true;
if (child.children?.length && this.hasRenderableChildren(child, user)) return true;
}
return false;
}
performDynamicClick(event: any, action: ActionItem<ActionableEntity>, dynamicItem: any) {
action._extra = dynamicItem;
this.performAction(event, action);
@@ -140,13 +148,13 @@ export class CardActionablesComponent implements OnDestroy {
openMobileActionableMenu(event: any) {
this.preventEvent(event);
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
ref.componentInstance.entity = this.entity();
ref.componentInstance.actions = this.actions();
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
ref.componentInstance.actionPerformed.subscribe((action: ActionItem<any>) => {
this.performAction(event, action);
// TODO: See if we can use a drawer instead
const ref = this.modalService.open(ActionableModalComponent);
ref.setInput('entity', this.entity());
ref.setInput('filteredActions', this.filteredActions());
ref.componentInstance.actionPerformed.subscribe((actionOrResult: any) => {
this.actionHandler.emit(actionOrResult);
});
}
}
@@ -30,7 +30,7 @@
</div>
</div>
<div class="text-end">
<app-card-actionables [actions]="actions()" [entity]="clientDevice()" [disabled]="clientDevice().ownerUserId != currentUserId()" />
<app-card-actionables [actions]="actions()" [entity]="clientDevice()" [disabled]="clientDevice().ownerUserId !== currentUserId()" (actionHandler)="handleActionCallback($event)" />
</div>
</div>
@@ -12,7 +12,6 @@ import {UtcToLocalDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {CardActionablesComponent} from "../card-actionables/card-actionables.component";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
import {DeviceService} from "../../_services/device.service";
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
@@ -20,6 +19,9 @@ import {DOCUMENT} from "@angular/common";
import {AccountService} from "../../_services/account.service";
import {User} from "../../_models/user/user";
import {Breakpoint, BreakpointService} from "../../_services/breakpoint.service";
import {ActionFactoryService} from "../../_services/action-factory.service";
import {ActionItem} from "../../_models/actionables/action-item";
import {ActionResult} from "../../_models/actionables/action-result";
@Component({
selector: 'app-client-device-card',
@@ -69,7 +71,7 @@ export class ClientDeviceCardComponent {
});
currentUserId = computed(() => {
return this.accountService.currentUserSignal()?.id;
return this.accountService.currentUser()?.id;
});
ipAddress = computed(() => {
@@ -154,25 +156,23 @@ export class ClientDeviceCardComponent {
constructor() {
const user = this.accountService.currentUserSignal();
if (user && !this.accountService.hasReadOnlyRole(user)) {
this.actions.set(this.actionFactoryService.getClientDeviceActions(this.handleActionCallback.bind(this), this.shouldRenderAction.bind(this)));
if (!this.accountService.hasReadOnlyRole()) {
this.actions.set(this.actionFactoryService.getClientDeviceActions(this.shouldRenderAction.bind(this)));
}
}
shouldRenderAction(action: ActionItem<ClientDevice>, entity: ClientDevice, user: User) {
const loggedInUser = this.accountService.currentUserSignal();
const loggedInUser = this.accountService.currentUser();
return entity.ownerUserId === loggedInUser?.id; // Only a user can manipulate their own devices
}
handleActionCallback(action: ActionItem<ClientDevice>, entity: ClientDevice) {
switch (action.action) {
case Action.Delete:
this.deleteDevice();
break;
case Action.Edit:
handleActionCallback(event: ActionResult<ClientDevice>) {
switch (event.effect) {
case 'update':
// We have purposely encoded Edit as an Update
// The actionable modal needs some time to clean up
if (this.breakpointService.activeBreakpoint() < Breakpoint.Tablet) {
setTimeout(() => this.toggleEdit(), 100);
@@ -180,6 +180,12 @@ export class ClientDeviceCardComponent {
this.toggleEdit();
}
break;
case 'remove':
this.deviceDeleted.emit(event.entity.id);
break;
case 'reload':
case 'none':
break;
}
}
@@ -191,20 +197,8 @@ export class ClientDeviceCardComponent {
});
}
deleteDevice() {
const id = this.clientDevice().id;
this.deviceService.deleteClientDevice(id).subscribe(successful => {
if (successful) {
this.deviceDeleted.emit(id);
}
});
}
toggleEdit() {
this.deviceForm.get('name')!.setValue(this.clientDevice().friendlyName);
this.isEditMode.update(x => !x);
}
}
@@ -7,7 +7,7 @@
<div class="card-overlay"></div>
<div class="overlay-information">
<div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;" (click)="read.emit()">
<span class="card-title library mx-auto" style="width: auto;" (click)="read.emit(undefined)">
<div>
<i class="fa-solid fa-book text-center" aria-hidden="true"></i>
</div>
@@ -1,10 +1,10 @@
.overlay-information {
position: relative;
top: -364px;
height: 364px;
top: -22.75rem;
height: 22.75rem;
transition: all 0.2s;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
&:hover {
cursor: pointer;
@@ -17,9 +17,9 @@
.overlay-information--centered {
position: absolute;
border-radius: 15px;
border-radius: 0.9375rem;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50px;
border-radius: 3.125rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@@ -32,11 +32,11 @@
}
div {
width: 60px;
height: 60px;
width: 3.75rem;
height: 3.75rem;
i {
font-size: 1.6rem;
line-height: 60px;
line-height: 3.75rem;
width: 100%;
}
}
@@ -46,12 +46,12 @@
.overlay-information {
position: absolute;
top: 0;
left: 12px;
width: calc(100% - 24px);
left: 0.75rem;
width: calc(100% - 1.5rem);
height: 100%;
transition: all 0.2s;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-top-left-radius: 0.25rem;
border-top-right-radius: 0.25rem;
&:hover {
background-color: var(--card-overlay-hover-bg-color);
@@ -64,9 +64,9 @@
.overlay-information--centered {
position: absolute;
border-radius: 15px;
border-radius: 0.9375rem;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50px;
border-radius: 3.125rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@@ -82,18 +82,18 @@
.series {
.overlay-information--centered {
div {
height: 32px;
width: 32px;
height: 2rem;
width: 2rem;
i {
font-size: 1.4rem;
line-height: 32px;
line-height: 2rem;
}
}
}
}
::ng-deep .image-container app-image img {
border-radius: 4px 4px 0 0;
border-radius: 0.25rem 0.25rem 0 0;
}
.progress {
@@ -118,8 +118,8 @@
.continue-from {
background-color: var(--breadcrumb-bg-color);
color: white;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
border-bottom-left-radius: 0.3125rem;
border-bottom-right-radius: 0.3125rem;
text-align: center;
position: absolute;
width: 100%;
@@ -129,7 +129,7 @@
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
padding: 0 10px 0 0;
padding: 0 0.625rem 0 0;
}
}
@@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, input, Output} from '@angular/core';
import {ChangeDetectionStrategy, Component, inject, input, output} from '@angular/core';
import {DecimalPipe, DOCUMENT} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
@@ -28,7 +28,7 @@ export class CoverImageComponent {
coverImage = input.required<string>();
entity = input.required<IHasProgress>();
continueTitle = input<string>('');
@Output() read = new EventEmitter();
readonly read = output();
mobileSeriesImgBackground = getComputedStyle(this.document.documentElement)
.getPropertyValue('--mobile-series-img-background').trim();
@@ -4,7 +4,7 @@
@let filePathsValue = filePaths();
@let filesValue = files();
@if (accountService.isAdmin() && (filePathsValue.length > 0 || filesValue.length > 0)) {
@if (accountService.hasAdminRole() && (filePathsValue.length > 0 || filesValue.length > 0)) {
<div class="mb-3 ms-1">
<h4 class="header">{{t(filesValue.length > 0 ? 'file-path-title' : 'folder-path-title')}}</h4>
<div class="ms-3 d-flex flex-column">
@@ -24,8 +24,7 @@
</div>
}
@if (!suppressEmptyGenres || genres().length > 0) {
@if (showGenres()) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('genres-title')}}</h4>
<div class="ms-3">
@@ -38,7 +37,7 @@
</div>
}
@if (!suppressEmptyTags || tags().length > 0) {
@if (showTags()) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('tags-title')}}</h4>
<div class="ms-3">
@@ -68,7 +67,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.writers" [title]="t('writers-title')">
<app-carousel-reel [items]="metadata().writers" [title]="t('writers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -76,7 +75,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.colorists" [title]="t('colorists-title')">
<app-carousel-reel [items]="metadata().colorists" [title]="t('colorists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -84,7 +83,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.editors" [title]="t('editors-title')">
<app-carousel-reel [items]="metadata().editors" [title]="t('editors-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -93,7 +92,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.coverArtists" [title]="t('cover-artists-title')">
<app-carousel-reel [items]="metadata().coverArtists" [title]="t('cover-artists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -101,7 +100,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.inkers" [title]="t('inkers-title')">
<app-carousel-reel [items]="metadata().inkers" [title]="t('inkers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -109,7 +108,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.letterers" [title]="t('letterers-title')">
<app-carousel-reel [items]="metadata().letterers" [title]="t('letterers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -117,7 +116,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.pencillers" [title]="t('pencillers-title')">
<app-carousel-reel [items]="metadata().pencillers" [title]="t('pencillers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -125,7 +124,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.translators" [title]="t('translators-title')">
<app-carousel-reel [items]="metadata().translators" [title]="t('translators-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -133,7 +132,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.characters" [title]="t('characters-title')">
<app-carousel-reel [items]="metadata().characters" [title]="t('characters-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -141,7 +140,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.locations" [title]="t('locations-title')">
<app-carousel-reel [items]="metadata().locations" [title]="t('locations-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -149,7 +148,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.teams" [title]="t('teams-title')">
<app-carousel-reel [items]="metadata().teams" [title]="t('teams-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -157,7 +156,7 @@
</div>
<div class="mb-3">
<app-carousel-reel [items]="metadata.imprints" [title]="t('imprints-title')">
<app-carousel-reel [items]="metadata().imprints" [title]="t('imprints-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" />
</ng-template>
@@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, Component, computed, inject, input, Input} from '@angular/core';
import {ChangeDetectionStrategy, Component, computed, inject, input} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
@@ -41,12 +41,12 @@ export class DetailsTabComponent {
protected readonly FilterField = FilterField;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) metadata!: IHasCast;
metadata = input.required<IHasCast>();
genres = input<Genre[]>([]);
tags = input<Tag[]>([]);
webLinks = input<string[]>([]);
@Input() suppressEmptyGenres: boolean = false;
@Input() suppressEmptyTags: boolean = false;
suppressEmptyGenres = input<boolean>(false);
suppressEmptyTags = input<boolean>(false);
filePaths = input<string[]>([]);
files = input<MangaFile[]>([]);
@@ -54,6 +54,9 @@ export class DetailsTabComponent {
return this.genres().length > 0 || this.tags().length > 0 || this.webLinks().length > 0;
});
showTags = computed(() => !this.suppressEmptyTags() || this.tags().length > 0);
showGenres = computed(() => !this.suppressEmptyGenres() || this.genres().length > 0);
openGeneric(queryParamName: FilterField, filter: string | number) {
if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
@@ -9,10 +9,10 @@
<form [formGroup]="editForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills"
[destroyOnHide]="false"
orientation="{{breakpointService.isMobile() ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
orientation="{{breakpointService.isMobile() ? 'horizontal' : 'vertical'}}" style="min-width: 8.4375rem;">
<!-- General Tab -->
@if (user && accountService.hasAdminRole(user))
@if (accountService.hasAdminRole())
{
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{t(TabID.General)}}</a>
@@ -161,7 +161,7 @@
<!-- Tags Tab -->
@if (user && accountService.hasAdminRole(user))
@if (accountService.hasAdminRole())
{
<li [ngbNavItem]="TabID.Tags">
<a ngbNavLink>{{t(TabID.Tags)}}</a>
@@ -332,7 +332,7 @@
<!-- People Tab -->
@if (user && accountService.hasAdminRole(user))
@if (accountService.hasAdminRole())
{
<li [ngbNavItem]="TabID.People">
<a ngbNavLink>{{t(TabID.People)}}</a>
@@ -523,7 +523,7 @@
<!-- Cover Tab -->
@if (user && accountService.hasAdminRole(user))
@if (accountService.hasAdminRole())
{
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
@@ -624,7 +624,7 @@
</div>
}
@if (accountService.isAdmin$ | async) {
@if (accountService.hasAdminRole()) {
<div class="row">
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
@@ -647,7 +647,7 @@
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
<ng-template ngbNavContent>
@for(task of tasks; track task.action) {
@if (accountService.canInvokeAction(user, task.action)) {
@if (accountService.canCurrentUserInvokeAction(task.action)) {
<div class="mt-3 mb-3">
<app-setting-button [subtitle]="task.description">
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>

Some files were not shown because too many files have changed in this diff Show More