mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-06-06 14:55:19 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -87,7 +87,7 @@
|
||||
"bookmark-dir-permissions": "Níl na ceadanna cearta ag an Eolaire Leabharmharcanna chun Kavita a úsáid",
|
||||
"total-backups": "Caithfidh Cúltaca Iomlána a bheith idir 1 agus 30",
|
||||
"total-logs": "Caithfidh an Logchomhaid Iomlán a bheith idir 1 agus 30",
|
||||
"url-not-valid": "Ní sheolann URL íomhá bhailí ar ais nó éilíonn sé údarú",
|
||||
"url-not-valid": "Ní thugann an URL íomhá bhailí ar ais nó teastaíonn údarú uaidh",
|
||||
"url-required": "Caithfidh tú pas a fháil i url le húsáid",
|
||||
"generic-cover-series-save": "Ní féidir íomhá an chlúdaigh a shábháil sa tSraith",
|
||||
"generic-cover-collection-save": "Ní féidir íomhá an chlúdaigh a shábháil sa Bhailiúchán",
|
||||
|
||||
+3
-3
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ public class LocalizationService : ILocalizationService
|
||||
try
|
||||
{
|
||||
var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-'));
|
||||
return cultureInfo.NativeName;
|
||||
return cultureInfo.EnglishName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,4 +20,4 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.assert" Version="2.9.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
};
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Series extends IHasCover, IHasReadingTime, IHasProgress {
|
||||
userRating: number;
|
||||
hasUserRated: boolean;
|
||||
libraryId: number;
|
||||
libraryName: string;
|
||||
/**
|
||||
* DateTime the entity was created
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>());
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {DestroyRef, inject, Injectable} from '@angular/core';
|
||||
import {interval, Subscription, switchMap} from 'rxjs';
|
||||
import {ServerService} from "./server.service";
|
||||
import {AccountService} from "./account.service";
|
||||
import {filter, take} from "rxjs/operators";
|
||||
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component";
|
||||
import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
||||
import {filter, map, take, tap} from "rxjs/operators";
|
||||
import {Router} from "@angular/router";
|
||||
import {OpdsName} from "../_models/user/auth-key";
|
||||
import {
|
||||
VersionUpdateModalComponent
|
||||
} from "../announcements/_components/version-update-modal/version-update-modal.component";
|
||||
import {versionNotifyModal, versionRefreshModal} from "../_models/modal/modal-options";
|
||||
import {UpdateVersionEvent} from "../_models/events/update-version-event";
|
||||
import {ModalService} from "./modal.service";
|
||||
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VersionService implements OnDestroy{
|
||||
export class VersionService {
|
||||
|
||||
private readonly serverService = inject(ServerService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly modalService = inject(ModalService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
public static readonly SERVER_VERSION_KEY = 'kavita--version';
|
||||
public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown';
|
||||
public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown';
|
||||
public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown';
|
||||
private static readonly DISMISS_KEY_PREFIX = 'kavita--update-dismiss-';
|
||||
|
||||
// Notification intervals
|
||||
private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once)
|
||||
private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds
|
||||
private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds
|
||||
|
||||
// Check intervals
|
||||
private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||||
private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours
|
||||
private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date"
|
||||
/** Threshold: above this count shows "out of date" instead of "update available" */
|
||||
private readonly OUT_OF_BAND_AMOUNT = 3;
|
||||
|
||||
// Routes where version update modals should not be shown
|
||||
/** Backoff intervals indexed by dismiss count: [after 1st dismiss, after 2nd dismiss] */
|
||||
private readonly BACKOFF_INTERVALS = [
|
||||
1 * 24 * 60 * 60 * 1000, // 1 day
|
||||
3 * 24 * 60 * 60 * 1000, // 3 days
|
||||
7 * 24 * 60 * 60 * 1000, // 1 week
|
||||
14 * 24 * 60 * 60 * 1000, // 2 weeks
|
||||
30 * 24 * 60 * 60 * 1000, // 1 month
|
||||
];
|
||||
/** After this many dismissals, Kavita will stop pestering the user */
|
||||
private readonly MAX_DISMISSALS = 5;
|
||||
|
||||
/** Routes where version update modals should not be shown */
|
||||
private readonly EXCLUDED_ROUTES = [
|
||||
'/manga/',
|
||||
'/book/',
|
||||
@@ -44,208 +52,227 @@ export class VersionService implements OnDestroy{
|
||||
|
||||
|
||||
private versionCheckSubscription?: Subscription;
|
||||
private outOfDateCheckSubscription?: Subscription;
|
||||
private modalOpen = false;
|
||||
/** Version fetched on initial page load — used to detect mid-session server updates */
|
||||
private loadedVersion: string | null = null;
|
||||
/** Tracks which version the currently-open modal is for, so we can record dismissal on close */
|
||||
private activeModalVersion: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.startInitialVersionCheck();
|
||||
this.startVersionCheck();
|
||||
this.startOutOfDateCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.versionCheckSubscription?.unsubscribe();
|
||||
this.outOfDateCheckSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial version check to ensure localStorage is populated on first load
|
||||
*/
|
||||
private startInitialVersionCheck(): void {
|
||||
this.accountService.currentUser$
|
||||
.pipe(
|
||||
filter(user => !!user),
|
||||
take(1),
|
||||
switchMap(user => this.serverService.getVersion(user!.authKeys.filter(k => k.name === OpdsName)[0].key))
|
||||
)
|
||||
.subscribe(serverVersion => {
|
||||
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
|
||||
|
||||
// Always update localStorage on first load
|
||||
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
|
||||
|
||||
console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion);
|
||||
});
|
||||
toObservable(this.accountService.currentUserGenericApiKey).pipe(
|
||||
filter((key): key is string => !!key),
|
||||
take(1),
|
||||
switchMap(key => this.serverService.getVersion(key))
|
||||
).subscribe(serverVersion => {
|
||||
this.loadedVersion = serverVersion;
|
||||
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
|
||||
this.cleanupOldDismissals(serverVersion);
|
||||
console.log('Initial version check - Server version:', serverVersion);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Periodic check for server version to detect client refreshes and new updates
|
||||
*/
|
||||
private startVersionCheck(): void {
|
||||
console.log('Starting version checker');
|
||||
this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL)
|
||||
.pipe(
|
||||
switchMap(() => this.accountService.currentUser$),
|
||||
filter(user => !!user && !this.modalOpen),
|
||||
switchMap(user => this.serverService.getVersion(user!.authKeys.filter(k => k.name === OpdsName)[0].key)),
|
||||
map(() => this.accountService.currentUserGenericApiKey()),
|
||||
filter((key): key is string => !!key && !this.modalOpen),
|
||||
switchMap(key => this.serverService.getVersion(key)),
|
||||
filter(update => !!update),
|
||||
).subscribe(version => this.handleVersionUpdate(version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server is out of date compared to the latest release
|
||||
*/
|
||||
private startOutOfDateCheck() {
|
||||
console.log('Starting out-of-date checker');
|
||||
this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL)
|
||||
.pipe(
|
||||
switchMap(() => this.accountService.currentUser$),
|
||||
filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen),
|
||||
switchMap(_ => this.serverService.checkHowOutOfDate(true)),
|
||||
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT),
|
||||
)
|
||||
.subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate));
|
||||
tap(serverVersion => this.handleVersionCheck(serverVersion)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current route is in the excluded routes list
|
||||
*/
|
||||
private isExcludedRoute(): boolean {
|
||||
isExcludedRoute(): boolean {
|
||||
const currentUrl = this.router.url;
|
||||
return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the version check response to determine if client refresh or new update notification is needed
|
||||
* Given a server version string, determines whether to show a refresh modal
|
||||
* (server updated mid-session) or check for available updates.
|
||||
*/
|
||||
private handleVersionUpdate(serverVersion: string) {
|
||||
if (this.modalOpen) return;
|
||||
handleVersionCheck(serverVersion: string): void {
|
||||
if (this.modalOpen || this.isExcludedRoute()) return;
|
||||
|
||||
// Validate if we are on a reader route and if so, suppress
|
||||
if (this.isExcludedRoute()) {
|
||||
console.log('Version update blocked due to user reading');
|
||||
return;
|
||||
}
|
||||
const isNewServerVersion = this.loadedVersion !== null && this.loadedVersion !== serverVersion;
|
||||
|
||||
const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY);
|
||||
console.log('Server version:', serverVersion, 'Cached version:', cachedVersion);
|
||||
|
||||
const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion;
|
||||
|
||||
// Case 1: Client Refresh needed (server has updated since last client load)
|
||||
if (isNewServerVersion) {
|
||||
this.showClientRefreshNotification(serverVersion);
|
||||
// Server was updated mid-session — don't update loadedVersion so the
|
||||
// refresh prompt persists until the user actually refreshes.
|
||||
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
|
||||
this.serverService.getChangelog(1).subscribe(changelog => {
|
||||
this.showRefreshModal(changelog[0]);
|
||||
localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString());
|
||||
});
|
||||
} else {
|
||||
this.handleUpdateCheck();
|
||||
}
|
||||
// Case 2: Check for new updates (for server admin)
|
||||
else {
|
||||
this.checkForNewUpdates();
|
||||
}
|
||||
|
||||
// Always update the cached version
|
||||
localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification that client refresh is needed due to server update
|
||||
* Checks if the admin should be notified of a new update or that the server is significantly out of date.
|
||||
* Single API call to checkHowOutOfDate determines which modal (if any) to show.
|
||||
*/
|
||||
private showClientRefreshNotification(newVersion: string): void {
|
||||
this.pauseChecks();
|
||||
|
||||
// Client refresh notifications should always show (once)
|
||||
this.modalOpen = true;
|
||||
|
||||
this.serverService.getChangelog(1).subscribe(changelog => {
|
||||
const ref = this.modalService.open(NewUpdateModalComponent, {
|
||||
size: 'lg',
|
||||
keyboard: false,
|
||||
backdrop: 'static' // Prevent closing by clicking outside
|
||||
});
|
||||
|
||||
ref.componentInstance.version = newVersion;
|
||||
ref.componentInstance.update = changelog[0];
|
||||
ref.componentInstance.requiresRefresh = true;
|
||||
|
||||
// Update the last shown timestamp
|
||||
localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString());
|
||||
|
||||
ref.closed.subscribe(_ => this.onModalClosed());
|
||||
ref.dismissed.subscribe(_ => this.onModalClosed());
|
||||
handleUpdateCheck(): void {
|
||||
if (!this.accountService.hasAdminRole()) return;
|
||||
this.serverService.checkHowOutOfDate().pipe(
|
||||
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0),
|
||||
).subscribe(versionsOutOfDate => {
|
||||
if (versionsOutOfDate > this.OUT_OF_BAND_AMOUNT) {
|
||||
this.handleOutOfDate(versionsOutOfDate);
|
||||
} else {
|
||||
this.handleUpdateAvailable(versionsOutOfDate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for new server updates and shows notification if appropriate
|
||||
* Given a versionsOutOfDate count (1–3), fetches changelog and shows the
|
||||
* update-available modal. Backoff is applied in showUpdateModal.
|
||||
*/
|
||||
private checkForNewUpdates(): void {
|
||||
this.accountService.currentUser$
|
||||
.pipe(
|
||||
take(1),
|
||||
filter(user => user !== undefined && this.accountService.hasAdminRole(user)),
|
||||
switchMap(_ => this.serverService.checkHowOutOfDate()),
|
||||
filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT)
|
||||
)
|
||||
.subscribe(versionsOutOfDate => {
|
||||
const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0');
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Show notification if it hasn't been shown in the last week
|
||||
if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) {
|
||||
this.pauseChecks();
|
||||
this.modalOpen = true;
|
||||
|
||||
this.serverService.getChangelog(1).subscribe(changelog => {
|
||||
const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' });
|
||||
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
|
||||
ref.componentInstance.update = changelog[0];
|
||||
ref.componentInstance.requiresRefresh = false;
|
||||
|
||||
// Update the last shown timestamp
|
||||
localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString());
|
||||
|
||||
ref.closed.subscribe(_ => this.onModalClosed());
|
||||
ref.dismissed.subscribe(_ => this.onModalClosed());
|
||||
});
|
||||
}
|
||||
});
|
||||
handleUpdateAvailable(versionsOutOfDate: number): void {
|
||||
this.serverService.getChangelog(1).subscribe(changelog => {
|
||||
this.showUpdateAvailableModal(changelog[0], versionsOutOfDate);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the notification for servers that are significantly out of date
|
||||
* Given a versionsOutOfDate count (4+), shows the out-of-date modal.
|
||||
* Backoff is applied in showUpdateModal.
|
||||
*/
|
||||
private handleOutOfDateNotification(versionsOutOfDate: number): void {
|
||||
const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0');
|
||||
const currentTime = Date.now();
|
||||
handleOutOfDate(versionsOutOfDate: number): void {
|
||||
this.showOutOfDateModal(versionsOutOfDate);
|
||||
}
|
||||
|
||||
// Show notification if it hasn't been shown in the last month
|
||||
if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) {
|
||||
this.pauseChecks();
|
||||
this.modalOpen = true;
|
||||
/**
|
||||
* Single entry point for opening version update modals.
|
||||
* Prevents stacking — only one modal can be open at a time.
|
||||
* For non-refresh modes, applies per-version backoff before opening.
|
||||
*/
|
||||
showUpdateModal(mode: 'refresh' | 'update-available' | 'out-of-date', data: { update?: UpdateVersionEvent | null, versionsOutOfDate?: number } = {}, force: boolean = false): void {
|
||||
if (this.modalOpen) return;
|
||||
|
||||
const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' });
|
||||
ref.componentInstance.versionsOutOfDate = versionsOutOfDate;
|
||||
// Per-version backoff for dismissible modes (skipped for refresh and user-initiated actions)
|
||||
if (mode !== 'refresh' && !force) {
|
||||
const backoffVersion = this.getBackoffVersion(mode, data);
|
||||
if (backoffVersion && !this.shouldShowNotification(backoffVersion)) return;
|
||||
this.activeModalVersion = backoffVersion;
|
||||
}
|
||||
|
||||
// Update the last shown timestamp
|
||||
localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString());
|
||||
this.pauseChecks();
|
||||
this.modalOpen = true;
|
||||
|
||||
ref.closed.subscribe(_ => this.onModalClosed());
|
||||
ref.dismissed.subscribe(_ => this.onModalClosed());
|
||||
const options = mode === 'refresh' ? versionRefreshModal() : versionNotifyModal();
|
||||
const ref = this.modalService.open(VersionUpdateModalComponent, options);
|
||||
ref.setInput('mode', mode);
|
||||
|
||||
if (data?.update != null) ref.setInput('update', data.update);
|
||||
if (data?.versionsOutOfDate != null) ref.setInput('versionsOutOfDate', data.versionsOutOfDate);
|
||||
|
||||
ref.closed.subscribe(_ => this.onModalClosed());
|
||||
ref.dismissed.subscribe(_ => this.onModalClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the refresh-required modal. The server was updated mid-session
|
||||
* and the browser needs to reload to pick up new client assets.
|
||||
*/
|
||||
showRefreshModal(update: UpdateVersionEvent): void {
|
||||
this.showUpdateModal('refresh', { update });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the update-available modal. A newer version exists that the admin can download.
|
||||
*/
|
||||
showUpdateAvailableModal(update: UpdateVersionEvent, versionsOutOfDate: number = 1): void {
|
||||
this.showUpdateModal('update-available', { update, versionsOutOfDate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the out-of-date warning modal. The server is significantly behind the latest release.
|
||||
*/
|
||||
showOutOfDateModal(versionsOutOfDate: number): void {
|
||||
this.showUpdateModal('out-of-date', { versionsOutOfDate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the version string used for backoff tracking.
|
||||
* update-available: the version available to update to.
|
||||
* out-of-date: the current server version (resets when user updates).
|
||||
*/
|
||||
private getBackoffVersion(mode: string, data: { update?: UpdateVersionEvent | null }): string | null {
|
||||
if (mode === 'update-available') return data.update?.updateVersion ?? null;
|
||||
if (mode === 'out-of-date') return this.loadedVersion;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks per-version dismissal history to determine if we should show the notification.
|
||||
* Returns false if the user has dismissed enough times or too recently.
|
||||
*/
|
||||
shouldShowNotification(targetVersion: string): boolean {
|
||||
const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion);
|
||||
if (!raw) return true;
|
||||
|
||||
const { count, lastDismissed } = JSON.parse(raw) as { count: number; lastDismissed: number };
|
||||
if (count >= this.MAX_DISMISSALS) return false;
|
||||
|
||||
const interval = this.BACKOFF_INTERVALS[Math.min(count - 1, this.BACKOFF_INTERVALS.length - 1)];
|
||||
return Date.now() - lastDismissed >= interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a dismissal for the given version, incrementing the count and updating the timestamp.
|
||||
*/
|
||||
recordDismissal(targetVersion: string): void {
|
||||
const raw = localStorage.getItem(VersionService.DISMISS_KEY_PREFIX + targetVersion);
|
||||
const current = raw ? JSON.parse(raw) as { count: number } : { count: 0 };
|
||||
localStorage.setItem(VersionService.DISMISS_KEY_PREFIX + targetVersion, JSON.stringify({
|
||||
count: current.count + 1,
|
||||
lastDismissed: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dismiss keys for versions other than the current server version.
|
||||
* Prevents stale backoff data from carrying over after an update.
|
||||
*/
|
||||
private cleanupOldDismissals(currentVersion: string): void {
|
||||
const keepKey = VersionService.DISMISS_KEY_PREFIX + currentVersion;
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(VersionService.DISMISS_KEY_PREFIX) && key !== keepKey) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses all version checks while modals are open
|
||||
*/
|
||||
private pauseChecks(): void {
|
||||
this.versionCheckSubscription?.unsubscribe();
|
||||
this.outOfDateCheckSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes all checks when modals are closed
|
||||
*/
|
||||
private onModalClosed(): void {
|
||||
if (this.activeModalVersion) {
|
||||
this.recordDismissal(this.activeModalVersion);
|
||||
this.activeModalVersion = null;
|
||||
}
|
||||
this.modalOpen = false;
|
||||
this.startVersionCheck();
|
||||
this.startOutOfDateCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+27
@@ -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>
|
||||
+129
@@ -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
Reference in New Issue
Block a user