UX Overhaul Part 1 (#3047)

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
Robbie Davis 2024-08-09 13:55:31 -04:00 committed by GitHub
parent 5934d516f3
commit ff79710ac6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
324 changed files with 11589 additions and 4598 deletions

6
.gitignore vendored
View File

@ -536,6 +536,6 @@ UI/Web/.angular/
BenchmarkDotNet.Artifacts
API.Tests/Services/Test Data/ImageService/Covers/*_output*
API.Tests/Services/Test Data/ImageService/Covers/*_baseline*
API.Tests/Services/Test Data/ImageService/Covers/index.html
API.Tests/Services/Test Data/ImageService/**/*_output*
API.Tests/Services/Test Data/ImageService/**/*_baseline*
API.Tests/Services/Test Data/ImageService/**/*.html

View File

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.12" />
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.14.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>

View File

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.22" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.22" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -1,9 +1,14 @@
using System.IO;
using System.Drawing;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Text;
using API.Entities.Enums;
using API.Services;
using EasyCaching.Core;
using Microsoft.Extensions.Logging;
using NetVips;
using NSubstitute;
using Xunit;
using Image = NetVips.Image;
@ -12,6 +17,7 @@ namespace API.Tests.Services;
public class ImageServiceTests
{
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers");
private readonly string _testDirectoryColorScapes = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/ColorScapes");
private const string OutputPattern = "_output";
private const string BaselinePattern = "_baseline";
@ -121,4 +127,98 @@ public class ImageServiceTests
File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString());
}
[Fact]
public void TestColorScapes()
{
// Step 1: Delete any images that have _output in the name
var outputFiles = Directory.GetFiles(_testDirectoryColorScapes, "*_output.*");
foreach (var file in outputFiles)
{
File.Delete(file);
}
// Step 2: Scan the _testDirectory for images
var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
// Step 3: Process each image
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var colors = ImageService.CalculateColorScape(imagePath);
// Generate primary color image
GenerateColorImage(colors.Primary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png"));
// Generate secondary color image
GenerateColorImage(colors.Secondary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png"));
}
// Step 4: Generate HTML file
GenerateHtmlFileForColorScape();
}
private static void GenerateColorImage(string hexColor, string outputPath)
{
var color = ImageService.HexToRgb(hexColor);
using var colorImage = Image.Black(200, 100);
using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 };
output.WriteToFile(outputPath);
}
private void GenerateHtmlFileForColorScape()
{
var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
var htmlBuilder = new StringBuilder();
htmlBuilder.AppendLine("<!DOCTYPE html>");
htmlBuilder.AppendLine("<html lang=\"en\">");
htmlBuilder.AppendLine("<head>");
htmlBuilder.AppendLine("<meta charset=\"UTF-8\">");
htmlBuilder.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
htmlBuilder.AppendLine("<title>Color Scape Comparison</title>");
htmlBuilder.AppendLine("<style>");
htmlBuilder.AppendLine("body { font-family: Arial, sans-serif; }");
htmlBuilder.AppendLine(".container { display: flex; flex-wrap: wrap; }");
htmlBuilder.AppendLine(".image-row { display: flex; align-items: center; margin-bottom: 20px; width: 100% }");
htmlBuilder.AppendLine(".image-row img { margin-right: 10px; max-width: 200px; height: auto; }");
htmlBuilder.AppendLine(".color-square { width: 100px; height: 100px; margin-right: 10px; }");
htmlBuilder.AppendLine("</style>");
htmlBuilder.AppendLine("</head>");
htmlBuilder.AppendLine("<body>");
htmlBuilder.AppendLine("<div class=\"container\">");
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var primaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png");
var secondaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png");
htmlBuilder.AppendLine("<div class=\"image-row\">");
htmlBuilder.AppendLine($"<p>{fileName}</p>");
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(imagePath)}\" alt=\"{fileName}\">");
if (File.Exists(primaryPath))
{
htmlBuilder.AppendLine($"<img class=\"color-square\" src=\"./{Path.GetFileName(primaryPath)}\" alt=\"{fileName} primary color\">");
}
if (File.Exists(secondaryPath))
{
htmlBuilder.AppendLine($"<img class=\"color-square\" src=\"./{Path.GetFileName(secondaryPath)}\" alt=\"{fileName} secondary color\">");
}
htmlBuilder.AppendLine("</div>");
}
htmlBuilder.AppendLine("</div>");
htmlBuilder.AppendLine("</body>");
htmlBuilder.AppendLine("</html>");
File.WriteAllText(Path.Combine(_testDirectoryColorScapes, "colorscape_index.html"), htmlBuilder.ToString());
}
}

View File

@ -52,7 +52,9 @@ public class ReadingListServiceTests
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null!);
_readingListService = new ReadingListService(_unitOfWork, Substitute.For<ILogger<ReadingListService>>(), Substitute.For<IEventHub>());
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem());
_readingListService = new ReadingListService(_unitOfWork, Substitute.For<ILogger<ReadingListService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(), ds);
_readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

@ -55,8 +55,8 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -70,14 +70,14 @@
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.62" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
@ -85,26 +85,26 @@
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="NetVips.Native" Version="8.15.2" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.2" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.28.0.94264">
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.22" />
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
<PackageReference Include="System.Drawing.Common" Version="8.0.7" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

View File

@ -0,0 +1,64 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Theme;
using API.Entities.Interfaces;
using API.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
[Authorize]
public class ColorScapeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public ColorScapeController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns the color scape for a series
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("series")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForSeries(int id)
{
var entity = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
/// <summary>
/// Returns the color scape for a volume
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("volume")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForVolume(int id)
{
var entity = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
/// <summary>
/// Returns the color scape for a chapter
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("chapter")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForChapter(int id)
{
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id);
return GetColorSpaceDto(entity);
}
private ActionResult<ColorScapeDto> GetColorSpaceDto(IHasCoverImage entity)
{
if (entity == null) return Ok(ColorScapeDto.Empty);
return Ok(new ColorScapeDto(entity.PrimaryColor, entity.SecondaryColor));
}
}

View File

@ -7,6 +7,7 @@ using API.DTOs.Device;
using API.Extensions;
using API.Services;
using API.SignalR;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
@ -24,20 +25,27 @@ public class DeviceController : BaseApiController
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly IMapper _mapper;
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService,
IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService)
IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_deviceService = deviceService;
_emailService = emailService;
_eventHub = eventHub;
_localizationService = localizationService;
_mapper = mapper;
}
/// <summary>
/// Creates a new Device
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult> CreateOrUpdateDevice(CreateDeviceDto dto)
public async Task<ActionResult<DeviceDto>> CreateOrUpdateDevice(CreateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
if (user == null) return Unauthorized();
@ -46,20 +54,22 @@ public class DeviceController : BaseApiController
var device = await _deviceService.Create(dto, user);
if (device == null)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-create"));
return Ok(_mapper.Map<DeviceDto>(device));
}
catch (KavitaException ex)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
return Ok();
}
/// <summary>
/// Updates an existing Device
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update")]
public async Task<ActionResult> UpdateDevice(UpdateDeviceDto dto)
public async Task<ActionResult<DeviceDto>> UpdateDevice(UpdateDeviceDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
if (user == null) return Unauthorized();
@ -67,7 +77,7 @@ public class DeviceController : BaseApiController
if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-update"));
return Ok();
return Ok(_mapper.Map<DeviceDto>(device));
}
/// <summary>

View File

@ -25,15 +25,18 @@ public class ImageController : BaseApiController
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly ILocalizationService _localizationService;
private readonly IReadingListService _readingListService;
/// <inheritdoc />
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
IImageService imageService, ILocalizationService localizationService)
IImageService imageService, ILocalizationService localizationService,
IReadingListService readingListService)
{
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_imageService = imageService;
_localizationService = localizationService;
_readingListService = readingListService;
}
/// <summary>
@ -42,7 +45,7 @@ public class ImageController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "apiKey"])]
public async Task<ActionResult> GetChapterCoverImage(int chapterId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -60,7 +63,7 @@ public class ImageController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["libraryId", "apiKey"])]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -78,7 +81,7 @@ public class ImageController : BaseApiController
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["volumeId", "apiKey"])]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -95,7 +98,7 @@ public class ImageController : BaseApiController
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["seriesId", "apiKey"])]
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId, string apiKey)
{
@ -116,7 +119,7 @@ public class ImageController : BaseApiController
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["collectionTagId", "apiKey"])]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -141,15 +144,17 @@ public class ImageController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("readinglist-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["readingListId", "apiKey"])]
public async Task<ActionResult> GetReadingListCoverImage(int readingListId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
{
var destFile = await GenerateReadingListCoverImage(readingListId);
var destFile = await _readingListService.GenerateReadingListCoverImage(readingListId);
if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image"));
return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile));
}
@ -158,22 +163,6 @@ public class ImageController : BaseApiController
return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path));
}
private async Task<string> GenerateReadingListCoverImage(int readingListId)
{
var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId);
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetReadingListFormat(readingListId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
destFile += settings.EncodeMediaAs.GetExtension();
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
private async Task<string> GenerateCollectionCoverImage(int collectionId)
{
var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId);
@ -186,6 +175,7 @@ public class ImageController : BaseApiController
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
// TODO: Refactor this so that collections have a dedicated cover image so we can calculate primary/secondary colors
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
@ -198,7 +188,8 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"
])]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -220,7 +211,7 @@ public class ImageController : BaseApiController
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("web-link")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["url", "apiKey"])]
public async Task<ActionResult> GetWebLinkImage(string url, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -258,7 +249,7 @@ public class ImageController : BaseApiController
/// <returns></returns>
[Authorize(Policy="RequireAdminRole")]
[HttpGet("cover-upload")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename", "apiKey"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["filename", "apiKey"])]
public async Task<ActionResult> GetCoverUploadImage(string filename, string apiKey)
{
if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest();

View File

@ -471,6 +471,7 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
{
Id = tag.Id.ToString(),
@ -539,6 +540,8 @@ public class OpdsController : BaseApiController
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix);
SetFeedId(feed, "reading-list");
AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/");
foreach (var readingListDto in readingLists)
{
feed.Entries.Add(new FeedEntry()
@ -555,6 +558,7 @@ public class OpdsController : BaseApiController
});
}
return CreateXmlResult(SerializeXml(feed));
}
@ -1014,7 +1018,7 @@ public class OpdsController : BaseApiController
};
}
private static void AddPagination(Feed feed, PagedList<SeriesDto> list, string href)
private static void AddPagination<T>(Feed feed, PagedList<T> list, string href)
{
var url = href;
if (href.Contains('?'))

View File

@ -748,7 +748,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult();
if (user.Bookmarks.IsNullOrEmpty()) return Ok();
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok();
if (!await _accountService.HasBookmarkPermission(user))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));

View File

@ -210,9 +210,13 @@ public class ServerController : BaseApiController
/// Pull the Changelog for Kavita from Github and display
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("changelog")]
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> GetChangelog()
{
// Strange bug where [Authorize] doesn't work
if (User.GetUserId() == 0) return Unauthorized();
return Ok(await _versionUpdaterService.GetAllReleases());
}

View File

@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@ -109,6 +108,7 @@ public class UploadController : BaseApiController
{
series.CoverImage = filePath;
series.CoverImageLocked = true;
_imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series);
}
@ -157,6 +157,7 @@ public class UploadController : BaseApiController
{
tag.CoverImage = filePath;
tag.CoverImageLocked = true;
_imageService.UpdateColorScape(tag);
_unitOfWork.CollectionTagRepository.Update(tag);
}
@ -208,6 +209,7 @@ public class UploadController : BaseApiController
{
readingList.CoverImage = filePath;
readingList.CoverImageLocked = true;
_imageService.UpdateColorScape(readingList);
_unitOfWork.ReadingListRepository.Update(readingList);
}
@ -327,15 +329,18 @@ public class UploadController : BaseApiController
{
chapter.CoverImage = filePath;
chapter.CoverImageLocked = true;
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
volume.CoverImage = chapter.CoverImage;
_imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false);
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
@ -391,6 +396,7 @@ public class UploadController : BaseApiController
if (!string.IsNullOrEmpty(filePath))
{
library.CoverImage = filePath;
_imageService.UpdateColorScape(library);
_unitOfWork.LibraryRepository.Update(library);
}
@ -426,12 +432,15 @@ public class UploadController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var originalFile = chapter.CoverImage;
chapter.CoverImage = string.Empty;
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!;
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
if (_unitOfWork.HasChanges())
@ -451,7 +460,4 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
}
}

View File

@ -10,7 +10,7 @@ namespace API.DTOs;
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto : IHasReadTimeEstimate
public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; init; }
/// <summary>
@ -159,4 +159,8 @@ public class ChapterDto : IHasReadTimeEstimate
public int TotalCount { get; set; }
#endregion
public string CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
}

View File

@ -1,11 +1,12 @@
using System;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Services.Plus;
namespace API.DTOs.Collection;
#nullable enable
public class AppUserCollectionDto
public class AppUserCollectionDto : IHasCoverImage
{
public int Id { get; init; }
public string Title { get; set; } = default!;
@ -17,6 +18,9 @@ public class AppUserCollectionDto
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>

10
API/DTOs/ColorScape.cs Normal file
View File

@ -0,0 +1,10 @@
namespace API.DTOs;
/// <summary>
/// A primary and secondary color
/// </summary>
public class ColorScape
{
public required string? Primary { get; set; }
public required string? Secondary { get; set; }
}

View File

@ -20,4 +20,6 @@ public class MediaErrorDto
/// Exception message
/// </summary>
public string Details { get; set; }
public DateTime CreatedUtc { get; set; }
}

View File

@ -1,9 +1,10 @@
using System;
using API.Entities.Interfaces;
namespace API.DTOs.ReadingLists;
#nullable enable
public class ReadingListDto
public class ReadingListDto : IHasCoverImage
{
public int Id { get; init; }
public string Title { get; set; } = default!;
@ -17,6 +18,10 @@ public class ReadingListDto
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
/// <summary>
/// Minimum Year the Reading List starts
/// </summary>

View File

@ -5,7 +5,7 @@ using API.Entities.Interfaces;
namespace API.DTOs;
#nullable enable
public class SeriesDto : IHasReadTimeEstimate
public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; init; }
public string? Name { get; init; }
@ -62,4 +62,8 @@ public class SeriesDto : IHasReadTimeEstimate
/// The last time the folder for this series was scanned
/// </summary>
public DateTime LastFolderScanned { get; set; }
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace API.DTOs.Theme;
#nullable enable
/// <summary>
/// A set of colors for the color scape system in the UI
/// </summary>
public class ColorScapeDto
{
public string? Primary { get; set; }
public string? Secondary { get; set; }
public ColorScapeDto(string? primary, string? secondary)
{
Primary = primary;
Secondary = secondary;
}
public static readonly ColorScapeDto Empty = new ColorScapeDto(null, null);
}

View File

@ -174,4 +174,6 @@ public class UserPreferencesDto
/// </summary>
[Required]
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
}

View File

@ -8,7 +8,7 @@ using API.Services.Tasks.Scanner.Parser;
namespace API.DTOs;
public class VolumeDto : IHasReadTimeEstimate
public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; set; }
/// <inheritdoc cref="Volume.MinNumber"/>
@ -62,4 +62,8 @@ public class VolumeDto : IHasReadTimeEstimate
{
return MinNumber.Is(Parser.SpecialVolumeNumber);
}
public string CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class CoverPrimaryColors : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Volume",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Volume",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Series",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Series",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "ReadingList",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "ReadingList",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Library",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Library",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "Chapter",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "Chapter",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PrimaryColor",
table: "AppUserCollection",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SecondaryColor",
table: "AppUserCollection",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Volume");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Volume");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Series");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Series");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "ReadingList");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "ReadingList");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Library");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Library");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "Chapter");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "Chapter");
migrationBuilder.DropColumn(
name: "PrimaryColor",
table: "AppUserCollection");
migrationBuilder.DropColumn(
name: "SecondaryColor",
table: "AppUserCollection");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.4");
modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -230,9 +230,15 @@ namespace API.Data.Migrations
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
@ -775,12 +781,18 @@ namespace API.Data.Migrations
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<DateTime>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<string>("SeriesGroup")
.HasColumnType("TEXT");
@ -999,6 +1011,12 @@ namespace API.Data.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
@ -1504,9 +1522,15 @@ namespace API.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<int>("StartingMonth")
.HasColumnType("INTEGER");
@ -1794,6 +1818,12 @@ namespace API.Data.Migrations
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<string>("SortName")
.HasColumnType("TEXT");
@ -1989,6 +2019,12 @@ namespace API.Data.Migrations
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("PrimaryColor")
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");

View File

@ -83,7 +83,7 @@ public class ReadingListRepository : IReadingListRepository
return await _context.ReadingList
.Where(c => c.Id == readingListId)
.Select(c => c.CoverImage)
.SingleOrDefaultAsync();
.FirstOrDefaultAsync();
}
public async Task<IList<string>> GetAllCoverImagesAsync()

View File

@ -10,7 +10,7 @@ namespace API.Entities;
/// <summary>
/// Represents a Collection of Series for a given User
/// </summary>
public class AppUserCollection : IEntityDate
public class AppUserCollection : IEntityDate, IHasCoverImage
{
public int Id { get; set; }
public required string Title { get; set; }
@ -23,11 +23,9 @@ public class AppUserCollection : IEntityDate
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
/// <summary>
/// Path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// The highest age rating from all Series within the collection

View File

@ -9,7 +9,7 @@ using API.Services.Tasks.Scanner.Parser;
namespace API.Entities;
public class Chapter : IEntityDate, IHasReadTimeEstimate
public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; set; }
/// <summary>
@ -46,11 +46,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
/// <summary>
/// Relative path to the (managed) image file representing the cover image
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles

View File

@ -0,0 +1,19 @@
namespace API.Entities.Interfaces;
public interface IHasCoverImage
{
/// <summary>
/// Absolute path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
/// <summary>
/// Primary color derived from the Cover Image
/// </summary>
public string? PrimaryColor { get; set; }
/// <summary>
/// Secondary color derived from the Cover Image
/// </summary>
public string? SecondaryColor { get; set; }
}

View File

@ -5,11 +5,13 @@ using API.Entities.Interfaces;
namespace API.Entities;
public class Library : IEntityDate
public class Library : IEntityDate, IHasCoverImage
{
public int Id { get; set; }
public required string Name { get; set; }
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
public LibraryType Type { get; set; }
/// <summary>
/// If Folder Watching is enabled for this library

View File

@ -10,7 +10,7 @@ namespace API.Entities;
/// <summary>
/// This is a collection of <see cref="ReadingListItem"/> which represent individual chapters and an order.
/// </summary>
public class ReadingList : IEntityDate
public class ReadingList : IEntityDate, IHasCoverImage
{
public int Id { get; init; }
public required string Title { get; set; }
@ -23,11 +23,9 @@ public class ReadingList : IEntityDate
/// Reading lists that are promoted are only done by admins
/// </summary>
public bool Promoted { get; set; }
/// <summary>
/// Absolute path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public string? PrimaryColor { get; set; }
public string? SecondaryColor { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>

View File

@ -3,11 +3,10 @@ using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Entities;
public class Series : IEntityDate, IHasReadTimeEstimate
public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; set; }
/// <summary>
@ -82,6 +81,9 @@ public class Series : IEntityDate, IHasReadTimeEstimate
/// </summary>
public MangaFormat Format { get; set; } = MangaFormat.Unknown;
public string PrimaryColor { get; set; } = string.Empty;
public string SecondaryColor { get; set; } = string.Empty;
public bool SortNameLocked { get; set; }
public bool LocalizedNameLocked { get; set; }

View File

@ -6,7 +6,7 @@ using API.Services.Tasks.Scanner.Parser;
namespace API.Entities;
public class Volume : IEntityDate, IHasReadTimeEstimate
public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
{
public int Id { get; set; }
/// <summary>
@ -38,11 +38,10 @@ public class Volume : IEntityDate, IHasReadTimeEstimate
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
/// <summary>
/// Absolute path to the (managed) image file
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string? CoverImage { get; set; }
public string PrimaryColor { get; set; }
public string SecondaryColor { get; set; }
/// <summary>
/// Total pages of all chapters in this volume
/// </summary>

View File

@ -1223,7 +1223,7 @@ public class BookService : IBookService
{
// Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
var coverImageContent = epubBook.Content.Cover
?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath
?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath))
?? epubBook.Content.Images.Local.FirstOrDefault();
if (coverImageContent == null) return string.Empty;

View File

@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using EasyCaching.Core;
using Flurl;
@ -13,6 +17,9 @@ using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using Image = NetVips.Image;
namespace API.Services;
@ -60,6 +67,7 @@ public interface IImageService
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
void UpdateColorScape(IHasCoverImage entity);
}
public class ImageService : IImageService
@ -73,6 +81,9 @@ public class ImageService : IImageService
public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+";
private const double WhiteThreshold = 0.90; // Colors with lightness above this are considered too close to white
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black
/// <summary>
/// Width of the Thumbnail generation
@ -415,13 +426,266 @@ public class ImageService : IImageService
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
return filename;
}catch (Exception ex)
} catch (Exception ex)
{
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
throw;
}
}
private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath)
{
using var image = Image.NewFromFile(imagePath);
// Resize the image to speed up processing
var resizedImage = image.Resize(0.1);
// Convert image to RGB array
var pixels = resizedImage.WriteToMemory().ToArray();
// Convert to list of Vector3 (RGB)
var rgbPixels = new List<Vector3>();
for (var i = 0; i < pixels.Length - 2; i += 3)
{
rgbPixels.Add(new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]));
}
// Perform k-means clustering
var clusters = KMeansClustering(rgbPixels, 4);
var sorted = SortByVibrancy(clusters);
if (sorted.Count >= 2)
{
return (sorted[0], sorted[1]);
}
if (sorted.Count == 1)
{
return (sorted[0], null);
}
return (null, null);
}
private static (Vector3?, Vector3?) GetPrimaryColorSharp(string imagePath)
{
using var image = SixLabors.ImageSharp.Image.Load<Rgb24>(imagePath);
image.Mutate(
x => x
// Scale the image down preserving the aspect ratio. This will speed up quantization.
// We use nearest neighbor as it will be the fastest approach.
.Resize(new ResizeOptions() { Sampler = KnownResamplers.NearestNeighbor, Size = new SixLabors.ImageSharp.Size(100, 0) })
// Reduce the color palette to 1 color without dithering.
.Quantize(new OctreeQuantizer(new QuantizerOptions { MaxColors = 4 })));
Rgb24 dominantColor = image[0, 0];
// This will give you a dominant color in HEX format i.e #5E35B1FF
return (new Vector3(dominantColor.R, dominantColor.G, dominantColor.B), new Vector3(dominantColor.R, dominantColor.G, dominantColor.B));
}
private static Image PreProcessImage(Image image)
{
// Create a mask for white and black pixels
var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100);
var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100);
// Create a replacement color (e.g., medium gray)
var replacementColor = new[] { 128.0, 128.0, 128.0 };
// Apply the masks to replace white and black pixels
var processedImage = image.Copy();
processedImage = processedImage.Ifthenelse(whiteMask, replacementColor);
processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
return processedImage;
}
private static Dictionary<Vector3, int> GenerateColorHistogram(Image image)
{
var pixels = image.WriteToMemory().ToArray();
var histogram = new Dictionary<Vector3, int>();
for (var i = 0; i < pixels.Length; i += 3)
{
var color = new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]);
if (!histogram.TryAdd(color, 1))
{
histogram[color]++;
}
}
return histogram;
}
private static bool IsColorCloseToWhiteOrBlack(Vector3 color)
{
var (_, _, lightness) = RgbToHsl(color);
return lightness is > WhiteThreshold or < BlackThreshold;
}
private static List<Vector3> KMeansClustering(List<Vector3> points, int k, int maxIterations = 100)
{
var random = new Random();
var centroids = points.OrderBy(x => random.Next()).Take(k).ToList();
for (var i = 0; i < maxIterations; i++)
{
var clusters = new List<Vector3>[k];
for (var j = 0; j < k; j++)
{
clusters[j] = [];
}
foreach (var point in points)
{
var nearestCentroidIndex = centroids
.Select((centroid, index) => new { Index = index, Distance = Vector3.DistanceSquared(centroid, point) })
.OrderBy(x => x.Distance)
.First().Index;
clusters[nearestCentroidIndex].Add(point);
}
var newCentroids = clusters.Select(cluster =>
cluster.Count != 0 ? new Vector3(
cluster.Average(p => p.X),
cluster.Average(p => p.Y),
cluster.Average(p => p.Z)
) : Vector3.Zero
).ToList();
if (centroids.SequenceEqual(newCentroids))
break;
centroids = newCentroids;
}
return centroids;
}
// public static Vector3 GetComplementaryColor(Vector3 color)
// {
// // Simple complementary color calculation
// return new Vector3(255 - color.X, 255 - color.Y, 255 - color.Z);
// }
public static List<Vector3> SortByBrightness(List<Vector3> colors)
{
return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList();
}
public static List<Vector3> SortByVibrancy(List<Vector3> colors)
{
return colors.OrderByDescending(c =>
{
float max = Math.Max(c.X, Math.Max(c.Y, c.Z));
float min = Math.Min(c.X, Math.Min(c.Y, c.Z));
return (max - min) / max;
}).ToList();
}
private static string RgbToHex(Vector3 color)
{
return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}";
}
private static Vector3 GetComplementaryColor(Vector3 color)
{
// Convert RGB to HSL
var (h, s, l) = RgbToHsl(color);
// Rotate hue by 180 degrees
h = (h + 180) % 360;
// Convert back to RGB
return HslToRgb(h, s, l);
}
private static (double H, double S, double L) RgbToHsl(Vector3 rgb)
{
double r = rgb.X / 255;
double g = rgb.Y / 255;
double b = rgb.Z / 255;
var max = Math.Max(r, Math.Max(g, b));
var min = Math.Min(r, Math.Min(g, b));
var diff = max - min;
double h = 0;
double s = 0;
var l = (max + min) / 2;
if (Math.Abs(diff) > 0.00001)
{
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
if (max == r)
h = (g - b) / diff + (g < b ? 6 : 0);
else if (max == g)
h = (b - r) / diff + 2;
else if (max == b)
h = (r - g) / diff + 4;
h *= 60;
}
return (h, s, l);
}
private static Vector3 HslToRgb(double h, double s, double l)
{
double r, g, b;
if (Math.Abs(s) < 0.00001)
{
r = g = b = l;
}
else
{
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = HueToRgb(p, q, h + 120);
g = HueToRgb(p, q, h);
b = HueToRgb(p, q, h - 120);
}
return new Vector3((float)(r * 255), (float)(g * 255), (float)(b * 255));
}
private static double HueToRgb(double p, double q, double t)
{
if (t < 0) t += 360;
if (t > 360) t -= 360;
return t switch
{
< 60 => p + (q - p) * t / 60,
< 180 => q,
< 240 => p + (q - p) * (240 - t) / 60,
_ => p
};
}
/// <summary>
/// Generates the Primary and Secondary colors from a file
/// </summary>
/// <remarks>This may use a second most common color or a complementary color. It's up to implemenation to choose what's best</remarks>
/// <param name="sourceFile"></param>
/// <returns></returns>
public static ColorScape CalculateColorScape(string sourceFile)
{
if (!File.Exists(sourceFile)) return new ColorScape() {Primary = null, Secondary = null};
var colors = GetPrimarySecondaryColors(sourceFile);
return new ColorScape()
{
Primary = colors.Item1 == null ? null : RgbToHex(colors.Item1.Value),
Secondary = colors.Item2 == null ? null : RgbToHex(colors.Item2.Value)
};
}
private static string FallbackToKavitaReaderFavicon(string baseUrl)
{
var correctSizeLink = string.Empty;
@ -582,4 +846,39 @@ public class ImageService : IImageService
image.WriteToFile(dest);
}
public void UpdateColorScape(IHasCoverImage entity)
{
var colors = CalculateColorScape(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, entity.CoverImage));
entity.PrimaryColor = colors.Primary;
entity.SecondaryColor = colors.Secondary;
}
public static Color HexToRgb(string? hex)
{
if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null");
// Remove the leading '#' if present
hex = hex.TrimStart('#');
// Ensure the hex string is valid
if (hex.Length != 6 && hex.Length != 3)
{
throw new ArgumentException("Hex string should be 6 or 3 characters long.");
}
if (hex.Length == 3)
{
// Expand shorthand notation to full form (e.g., "abc" -> "aabbcc")
hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]);
}
// Parse the hex string into RGB components
var r = Convert.ToInt32(hex.Substring(0, 2), 16);
var g = Convert.ToInt32(hex.Substring(2, 2), 16);
var b = Convert.ToInt32(hex.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
}

View File

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using API.Helpers;
using API.SignalR;
@ -38,6 +40,9 @@ public interface IMetadataService
Task RemoveAbandonedMetadataKeys();
}
/// <summary>
/// Handles everything around Cover/ColorScape management
/// </summary>
public class MetadataService : IMetadataService
{
public const string Name = "MetadataService";
@ -47,10 +52,13 @@ public class MetadataService : IMetadataService
private readonly ICacheHelper _cacheHelper;
private readonly IReadingItemService _readingItemService;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly IList<SignalRMessage> _updateEvents = new List<SignalRMessage>();
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
IEventHub eventHub, ICacheHelper cacheHelper,
IReadingItemService readingItemService, IDirectoryService directoryService)
IReadingItemService readingItemService, IDirectoryService directoryService,
IImageService imageService)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -58,6 +66,7 @@ public class MetadataService : IMetadataService
_cacheHelper = cacheHelper;
_readingItemService = readingItemService;
_directoryService = directoryService;
_imageService = imageService;
}
/// <summary>
@ -71,16 +80,28 @@ public class MetadataService : IMetadataService
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false);
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
if (!_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
return Task.FromResult(false);
{
if (NeedsColorSpace(chapter))
{
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
}
return Task.FromResult(false);
}
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath,
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize);
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
@ -95,6 +116,15 @@ public class MetadataService : IMetadataService
firstFile.UpdateLastModified();
}
private static bool NeedsColorSpace(IHasCoverImage? entity)
{
if (entity == null) return false;
return !string.IsNullOrEmpty(entity.CoverImage) &&
(string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor));
}
/// <summary>
/// Updates the cover image for a Volume
/// </summary>
@ -105,8 +135,16 @@ public class MetadataService : IMetadataService
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage),
null, volume.Created, forceUpdate)) return Task.FromResult(false);
null, volume.Created, forceUpdate))
{
if (NeedsColorSpace(volume))
{
_imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume));
}
return Task.FromResult(false);
}
// For cover selection, chapters need to try for issue 1 first, then fallback to first sort order
volume.Chapters ??= new List<Chapter>();
@ -118,7 +156,10 @@ public class MetadataService : IMetadataService
if (firstChapter == null) return Task.FromResult(false);
}
volume.CoverImage = firstChapter.CoverImage;
_imageService.UpdateColorScape(volume);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume));
return Task.FromResult(true);
@ -133,13 +174,26 @@ public class MetadataService : IMetadataService
{
if (series == null) return Task.CompletedTask;
if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage),
if (!_cacheHelper.ShouldUpdateCoverImage(
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage),
null, series.Created, forceUpdate, series.CoverImageLocked))
{
// Check if we don't have a primary/seconary color
if (NeedsColorSpace(series))
{
_imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
}
return Task.CompletedTask;
}
series.Volumes ??= [];
series.CoverImage = series.GetCoverImage();
_imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
return Task.CompletedTask;
}

View File

@ -48,6 +48,7 @@ public interface IReadingListService
Task CreateReadingListsFromSeries(Series series, Library library);
Task CreateReadingListsFromSeries(int libraryId, int seriesId);
Task<string> GenerateReadingListCoverImage(int readingListId);
}
/// <summary>
@ -59,15 +60,20 @@ public class ReadingListService : IReadingListService
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReadingListService> _logger;
private readonly IEventHub _eventHub;
private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default;
private readonly IImageService _imageService;
private readonly IDirectoryService _directoryService;
private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase,
Parser.RegexTimeout);
public ReadingListService(IUnitOfWork unitOfWork, ILogger<ReadingListService> logger, IEventHub eventHub)
public ReadingListService(IUnitOfWork unitOfWork, ILogger<ReadingListService> logger,
IEventHub eventHub, IImageService imageService, IDirectoryService directoryService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_eventHub = eventHub;
_imageService = imageService;
_directoryService = directoryService;
}
public static string FormatTitle(ReadingListItemDto item)
@ -488,8 +494,12 @@ public class ReadingListService : IReadingListService
if (!_unitOfWork.HasChanges()) continue;
_imageService.UpdateColorScape(readingList);
await CalculateReadingListAgeRating(readingList);
await _unitOfWork.CommitAsync(); // TODO: See if we can avoid this extra commit by reworking bottom logic
await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1,
user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter));
await _unitOfWork.CommitAsync();
@ -632,6 +642,7 @@ public class ReadingListService : IReadingListService
var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName);
var readingListNameNormalized = Parser.Normalize(cblReading.Name);
// Get all the user's reading lists
var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle);
if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList))
@ -736,7 +747,10 @@ public class ReadingListService : IReadingListService
}
// If there are no items, don't create a blank list
if (!_unitOfWork.HasChanges() || !readingList.Items.Any()) return importSummary;
if (!_unitOfWork.HasChanges() || readingList.Items.Count == 0) return importSummary;
_imageService.UpdateColorScape(readingList);
await _unitOfWork.CommitAsync();
@ -787,4 +801,33 @@ public class ReadingListService : IReadingListService
file.Close();
return cblReadingList;
}
public async Task<string> GenerateReadingListCoverImage(int readingListId)
{
// TODO: Currently reading lists are dynamically generated at runtime. This needs to be overhauled to be generated and stored within
// the Reading List (and just expire every so often) so we can utilize ColorScapes.
// Check if a cover already exists for the reading list
// var potentialExistingCoverPath = _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory,
// ImageService.GetReadingListFormat(readingListId));
// if (_directoryService.FileSystem.File.Exists(potentialExistingCoverPath))
// {
// // Check if we need to update CoverScape
//
// }
var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId);
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetReadingListFormat(readingListId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
destFile += settings.EncodeMediaAs.GetExtension();
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
// TODO: Refactor this so that reading lists have a dedicated cover image so we can calculate primary/secondary colors
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
}

View File

@ -97,7 +97,6 @@ public class VersionUpdaterService : IVersionUpdaterService
// isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
if (IsVersionEqualToBuildVersion(updateVersion))
{
//latestRelease.UpdateVersion = BuildInfo.Version.ToString();
isNightly = false;
}

View File

@ -14,10 +14,10 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.28.0.94264">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.assert" Version="2.8.1" />
<PackageReference Include="xunit.assert" Version="2.9.0" />
</ItemGroup>
</Project>

View File

@ -504,7 +504,6 @@
"version": "17.3.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
"dev": true,
"dependencies": {
"@babel/core": "7.23.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -532,7 +531,6 @@
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -561,14 +559,12 @@
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -749,7 +745,6 @@
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -778,14 +773,12 @@
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -5629,7 +5622,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -5642,7 +5634,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -5914,7 +5905,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
@ -6226,7 +6216,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -6518,8 +6507,7 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cookie": {
"version": "0.6.0",
@ -7421,7 +7409,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -7431,7 +7418,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8540,7 +8526,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -9222,7 +9207,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -11063,7 +11047,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -12453,7 +12436,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -12465,7 +12447,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -12476,8 +12457,7 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/regenerate": {
"version": "1.4.2",
@ -12945,7 +12925,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"devOptional": true
},
"node_modules/sass": {
"version": "1.71.1",
@ -13064,7 +13044,6 @@
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@ -13079,7 +13058,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -13090,8 +13068,7 @@
"node_modules/semver/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/send": {
"version": "0.18.0",
@ -14222,7 +14199,6 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -79,4 +79,7 @@ export interface Chapter {
translators: Array<Person>;
teams: Array<Person>;
locations: Array<Person>;
primaryColor?: string;
secondaryColor?: string;
}

View File

@ -31,6 +31,8 @@ export interface ReadingList {
* If this is empty or null, the cover image isn't set. Do not use this externally.
*/
coverImage: string;
primaryColor?: string;
secondaryColor?: string;
startingYear: number;
startingMonth: number;
endingYear: number;

View File

@ -64,4 +64,7 @@ export interface Series {
* This is currently only used on Series detail page for recommendations
*/
summary?: string;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
}

View File

@ -0,0 +1,4 @@
export interface ColorScape {
primary?: string;
secondary?: string;
}

View File

@ -18,4 +18,8 @@ export interface Volume {
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
}

View File

@ -18,5 +18,7 @@ export enum WikiLink {
ScannerExclude = 'https://wiki.kavitareader.com/guides/admin-settings/libraries#exclude-patterns',
Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries',
UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native',
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker'
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker',
OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients',
Guides = 'https://wiki.kavitareader.com/guides'
}

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
@Pipe({
name: 'bookPageLayoutMode',
standalone: true
})
export class BookPageLayoutModePipe implements PipeTransform {
transform(value: BookPageLayoutMode): string {
switch (value) {
case BookPageLayoutMode.Column1: return translate('preferences.1-column');
case BookPageLayoutMode.Column2: return translate('preferences.2-column');
case BookPageLayoutMode.Default: return translate('preferences.scroll');
}
}
}

View File

@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
import {CoverImageSize} from "../admin/_models/cover-image-size";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'coverImageSize',
standalone: true
})
export class CoverImageSizePipe implements PipeTransform {
transform(value: CoverImageSize): string {
switch (value) {
case CoverImageSize.Default:
return translate('cover-image-size.default');
case CoverImageSize.Medium:
return translate('cover-image-size.medium');
case CoverImageSize.Large:
return translate('cover-image-size.large');
case CoverImageSize.XLarge:
return translate('cover-image-size.xlarge');
}
}
}

View File

@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import {EncodeFormat} from "../admin/_models/encode-format";
@Pipe({
name: 'encodeFormat',
standalone: true
})
export class EncodeFormatPipe implements PipeTransform {
transform(value: EncodeFormat): string {
switch (value) {
case EncodeFormat.PNG:
return 'PNG';
case EncodeFormat.WebP:
return 'WebP';
case EncodeFormat.AVIF:
return 'AVIF';
}
}
}

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {LayoutMode} from "../manga-reader/_models/layout-mode";
@Pipe({
name: 'layoutMode',
standalone: true
})
export class LayoutModePipe implements PipeTransform {
transform(value: LayoutMode): string {
switch (value) {
case LayoutMode.Single: return translate('preferences.single');
case LayoutMode.Double: return translate('preferences.double');
case LayoutMode.DoubleReversed: return translate('preferences.double-manga');
case LayoutMode.DoubleNoCover: return translate('preferences.double-no-cover');
}
}
}

View File

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PageLayoutMode} from "../_models/page-layout-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pageLayoutMode',
standalone: true
})
export class PageLayoutModePipe implements PipeTransform {
transform(value: PageLayoutMode): string {
switch (value) {
case PageLayoutMode.Cards: return translate('preferences.cards');
case PageLayoutMode.List: return translate('preferences.list');
}
}
}

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {PageSplitOption} from "../_models/preferences/page-split-option";
@Pipe({
name: 'pageSplitOption',
standalone: true
})
export class PageSplitOptionPipe implements PipeTransform {
transform(value: PageSplitOption): string {
switch (value) {
case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen');
case PageSplitOption.NoSplit: return translate('preferences.no-split');
case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right');
case PageSplitOption.SplitRightToLeft: return translate('preferences.split-right-to-left');
}
}
}

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode";
@Pipe({
name: 'pdfScrollMode',
standalone: true
})
export class PdfScrollModePipe implements PipeTransform {
transform(value: PdfScrollMode): string {
switch (value) {
case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple');
case PdfScrollMode.Page: return translate('preferences.pdf-page');
case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal');
case PdfScrollMode.Vertical: return translate('preferences.pdf-vertical');
}
}
}

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pdfSpreadMode',
standalone: true
})
export class PdfSpreadModePipe implements PipeTransform {
transform(value: PdfSpreadMode): string {
switch (value) {
case PdfSpreadMode.None: return translate('preferences.pdf-none');
case PdfSpreadMode.Odd: return translate('preferences.pdf-odd');
case PdfSpreadMode.Even: return translate('preferences.pdf-even');
}
}
}

View File

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PdfTheme} from "../_models/preferences/pdf-theme";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pdfTheme',
standalone: true
})
export class PdfThemePipe implements PipeTransform {
transform(value: PdfTheme): string {
switch (value) {
case PdfTheme.Dark: return translate('preferences.pdf-dark');
case PdfTheme.Light: return translate('preferences.pdf-light');
}
}
}

View File

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ReadingDirection} from "../_models/preferences/reading-direction";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'readingDirection',
standalone: true
})
export class ReadingDirectionPipe implements PipeTransform {
transform(value: ReadingDirection): string {
switch (value) {
case ReadingDirection.LeftToRight: return translate('preferences.left-to-right');
case ReadingDirection.RightToLeft: return translate('preferences.right-to-right');
}
}
}

View File

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ReaderMode} from "../_models/preferences/reader-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'readerMode',
standalone: true
})
export class ReaderModePipe implements PipeTransform {
transform(value: ReaderMode): string {
switch (value) {
case ReaderMode.UpDown: return translate('preferences.up-down');
case ReaderMode.Webtoon: return translate('preferences.webtoon');
case ReaderMode.LeftRight: return translate('preferences.left-to-right');
}
}
}

View File

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {ScalingOption} from "../_models/preferences/scaling-option";
@Pipe({
name: 'scalingOption',
standalone: true
})
export class ScalingOptionPipe implements PipeTransform {
transform(value: ScalingOption): string {
switch (value) {
case ScalingOption.Automatic: return translate('preferences.automatic');
case ScalingOption.FitToHeight: return translate('preferences.fit-to-height');
case ScalingOption.FitToWidth: return translate('preferences.fit-to-width');
case ScalingOption.Original: return translate('preferences.original');
}
}
}

View File

@ -0,0 +1,19 @@
import {Pipe, PipeTransform} from '@angular/core';
import {ScrobbleProvider} from "../_services/scrobbling.service";
@Pipe({
name: 'scrobbleProviderName',
standalone: true
})
export class ScrobbleProviderNamePipe implements PipeTransform {
transform(value: ScrobbleProvider): string {
switch (value) {
case ScrobbleProvider.AniList: return 'AniList';
case ScrobbleProvider.Mal: return 'MAL';
case ScrobbleProvider.Kavita: return 'Kavita';
case ScrobbleProvider.GoogleBooks: return 'Google Books';
}
}
}

View File

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {translate} from "@ngneat/transloco";
/**
* Translates the fragment for Settings to a User title
*/
@Pipe({
name: 'settingFragment',
standalone: true
})
export class SettingFragmentPipe implements PipeTransform {
transform(tabID: SettingsTabId | string): string {
return translate('settings.' + tabID);
}
}

View File

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {WritingStyle} from "../_models/preferences/writing-style";
@Pipe({
name: 'writingStyle',
standalone: true
})
export class WritingStylePipe implements PipeTransform {
transform(value: WritingStyle): string {
switch (value) {
case WritingStyle.Horizontal: return translate('preferences.horizontal');
case WritingStyle.Vertical: return translate('preferences.vertical');
}
}
}

View File

@ -1,16 +0,0 @@
import { Routes } from '@angular/router';
import { AdminGuard } from '../_guards/admin.guard';
import { DashboardComponent } from '../admin/dashboard/dashboard.component';
export const routes: Routes = [
{path: '**', component: DashboardComponent, pathMatch: 'full', canActivate: [AdminGuard]},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AdminGuard],
children: [
{path: 'dashboard', component: DashboardComponent},
]
}
];

View File

@ -0,0 +1,6 @@
import { Routes } from '@angular/router';
import {SettingsComponent} from "../settings/_components/settings/settings.component";
export const routes: Routes = [
{path: '', component: SettingsComponent, pathMatch: 'full'},
];

View File

@ -1,6 +0,0 @@
import { Routes } from '@angular/router';
import { UserPreferencesComponent } from '../user-settings/user-preferences/user-preferences.component';
export const routes: Routes = [
{path: '', component: UserPreferencesComponent, pathMatch: 'full'},
];

View File

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import {DestroyRef, inject, Injectable } from '@angular/core';
import {catchError, of, ReplaySubject, throwError} from 'rxjs';
import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences';
@ -42,6 +42,10 @@ export class AccountService {
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User | undefined>(1);
public currentUser$ = this.currentUserSource.asObservable();
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}));
private hasValidLicenseSource = new ReplaySubject<boolean>(1);
/**
@ -74,6 +78,17 @@ export class AccountService {
});
}
hasAnyRole(user: User, roles: Array<Role>) {
if (!user || !user.roles) {
return false;
}
if (roles.length === 0) {
return true;
}
return roles.some(role => user.roles.includes(role));
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);
}

View File

@ -102,7 +102,11 @@ export enum Action {
* Promotes the underlying item (Reading List, Collection)
*/
Promote = 24,
UnPromote = 25
UnPromote = 25,
/**
* Invoke a refresh covers as false to generate colorscapes
*/
GenerateColorScape = 26,
}
/**
@ -245,6 +249,13 @@ export class ActionFactoryService {
requiresAdmin: true,
children: [],
},
{
action: Action.GenerateColorScape,
title: 'generate-colorscape',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.AnalyzeFiles,
title: 'analyze-files',

View File

@ -84,23 +84,25 @@ export class ActionService implements OnDestroy {
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @param forceUpdate Optional Should we force
* @returns
*/
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback, forceUpdate: boolean = true) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
if (callback) {
callback(library);
// Prompt the user if we are doing a forced call
if (forceUpdate) {
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
if (callback) {
callback(library);
}
return;
}
return;
}
const forceUpdate = true; //await this.promptIfForce();
this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => {
this.libraryService.refreshMetadata(library?.id, forceUpdate).subscribe((res: any) => {
this.toastr.info(translate('toasts.scan-queued', {name: library.name}));
if (callback) {
callback(library);
@ -467,7 +469,7 @@ export class ActionService implements OnDestroy {
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id);
this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id);
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple;
@ -507,7 +509,7 @@ export class ActionService implements OnDestroy {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id);
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series;
@ -535,7 +537,7 @@ export class ActionService implements OnDestroy {
if (this.collectionModalRef != null) { return; }
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' });
this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id);
this.collectionModalRef.componentInstance.title = 'New Collection';
this.collectionModalRef.componentInstance.title = translate('action.new-collection');
this.collectionModalRef.closed.pipe(take(1)).subscribe(() => {
this.collectionModalRef = null;

View File

@ -0,0 +1,386 @@
import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
interface ColorSpace {
primary: string;
lighter: string;
darker: string;
complementary: string;
}
interface ColorSpaceRGBA {
primary: RGBAColor;
lighter: RGBAColor;
darker: RGBAColor;
complementary: RGBAColor;
}
interface RGBAColor {
r: number;
g: number;
b: number;
a: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
const colorScapeSelector = 'colorscape';
/**
* ColorScape handles setting the scape and managing the transitions
*/
@Injectable({
providedIn: 'root'
})
export class ColorscapeService {
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
public colors$ = this.colorSubject.asObservable();
private minDuration = 1000; // minimum duration
private maxDuration = 4000; // maximum duration
constructor(@Inject(DOCUMENT) private document: Document) {
}
/**
* Sets a color scape for the active theme
* @param primaryColor
* @param complementaryColor
*/
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
if (this.getCssVariable('--colorscape-enabled') === 'false') {
return;
}
const elem = this.document.querySelector('#backgroundCanvas');
if (!elem) {
return;
}
const newColors: ColorSpace = primaryColor ?
this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) :
this.defaultColors();
const newColorsRGBA = this.convertColorsToRGBA(newColors);
const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors());
const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA);
// Check if the colors we are transitioning to are visually equal
if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) {
return;
}
this.animateColorTransition(oldColors, newColorsRGBA, duration);
this.colorSubject.next(newColorsRGBA);
}
private areColorSpacesVisuallyEqual(color1: ColorSpaceRGBA, color2: ColorSpaceRGBA, threshold: number = 0): boolean {
return this.areRGBAColorsVisuallyEqual(color1.primary, color2.primary, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.lighter, color2.lighter, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.darker, color2.darker, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.complementary, color2.complementary, threshold);
}
private areRGBAColorsVisuallyEqual(color1: RGBAColor, color2: RGBAColor, threshold: number = 0): boolean {
return Math.abs(color1.r - color2.r) <= threshold &&
Math.abs(color1.g - color2.g) <= threshold &&
Math.abs(color1.b - color2.b) <= threshold &&
Math.abs(color1.a - color2.a) <= threshold / 255;
}
private convertColorsToRGBA(colors: ColorSpace): ColorSpaceRGBA {
return {
primary: this.parseColorToRGBA(colors.primary),
lighter: this.parseColorToRGBA(colors.lighter),
darker: this.parseColorToRGBA(colors.darker),
complementary: this.parseColorToRGBA(colors.complementary)
};
}
private parseColorToRGBA(color: string): RGBAColor {
if (color.startsWith('#')) {
return this.hexToRGBA(color);
} else if (color.startsWith('rgb')) {
return this.rgbStringToRGBA(color);
} else {
console.warn(`Unsupported color format: ${color}. Defaulting to black.`);
return { r: 0, g: 0, b: 0, a: 1 };
}
}
private hexToRGBA(hex: string, opacity: number = 1): RGBAColor {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: opacity
}
: { r: 0, g: 0, b: 0, a: opacity };
}
private rgbStringToRGBA(rgb: string): RGBAColor {
const matches = rgb.match(/(\d+(\.\d+)?)/g);
if (matches) {
return {
r: parseInt(matches[0], 10),
g: parseInt(matches[1], 10),
b: parseInt(matches[2], 10),
a: matches.length === 4 ? parseFloat(matches[3]) : 1
};
}
return { r: 0, g: 0, b: 0, a: 1 };
}
private calculateTransitionDuration(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA): number {
const colorKeys: (keyof ColorSpaceRGBA)[] = ['primary', 'lighter', 'darker', 'complementary'];
let totalDistance = 0;
for (const key of colorKeys) {
const oldRGB = this.rgbaToRgb(oldColors[key]);
const newRGB = this.rgbaToRgb(newColors[key]);
totalDistance += this.calculateColorDistance(oldRGB, newRGB);
}
// Normalize the total distance and map it to our duration range
const normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4
const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration);
return Math.round(duration);
}
private rgbaToRgb(rgba: RGBAColor): RGB {
return { r: rgba.r, g: rgba.g, b: rgba.b };
}
private calculateColorDistance(rgb1: RGB, rgb2: RGB): number {
return Math.sqrt(
Math.pow(rgb2.r - rgb1.r, 2) +
Math.pow(rgb2.g - rgb1.g, 2) +
Math.pow(rgb2.b - rgb1.b, 2)
);
}
private defaultColors() {
return {
primary: this.getCssVariable('--colorscape-primary-default-color'),
lighter: this.getCssVariable('--colorscape-lighter-default-color'),
darker: this.getCssVariable('--colorscape-darker-default-color'),
complementary: this.getCssVariable('--colorscape-complementary-default-color'),
}
}
private animateColorTransition(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA, duration: number) {
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
const interpolatedColors: ColorSpaceRGBA = {
primary: this.interpolateRGBAColor(oldColors.primary, newColors.primary, progress),
lighter: this.interpolateRGBAColor(oldColors.lighter, newColors.lighter, progress),
darker: this.interpolateRGBAColor(oldColors.darker, newColors.darker, progress),
complementary: this.interpolateRGBAColor(oldColors.complementary, newColors.complementary, progress)
};
this.setColorsImmediately(interpolatedColors);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor {
return {
r: Math.round(color1.r + (color2.r - color1.r) * progress),
g: Math.round(color1.g + (color2.g - color1.g) * progress),
b: Math.round(color1.b + (color2.b - color1.b) * progress),
a: color1.a + (color2.a - color1.a) * progress
};
}
private setColorsImmediately(colors: ColorSpaceRGBA) {
this.injectStyleElement(colorScapeSelector, `
:root, :root .default {
--colorscape-primary-color: ${this.rgbaToString(colors.primary)};
--colorscape-lighter-color: ${this.rgbaToString(colors.lighter)};
--colorscape-darker-color: ${this.rgbaToString(colors.darker)};
--colorscape-complementary-color: ${this.rgbaToString(colors.complementary)};
--colorscape-primary-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
--colorscape-lighter-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
--colorscape-darker-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
--colorscape-complementary-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })};
}
`);
}
private generateBackgroundColors(primaryColor: string, secondaryColor: string | null = null, isDarkTheme: boolean = true): ColorSpace {
const primary = this.hexToRgb(primaryColor);
const secondary = secondaryColor ? this.hexToRgb(secondaryColor) : this.calculateComplementaryRgb(primary);
const primaryHSL = this.rgbToHsl(primary);
const secondaryHSL = this.rgbToHsl(secondary);
if (isDarkTheme) {
const lighterHSL = this.adjustHue(secondaryHSL, 30);
lighterHSL.s = Math.min(lighterHSL.s + 0.2, 1);
lighterHSL.l = Math.min(lighterHSL.l + 0.1, 0.6);
const darkerHSL = { ...primaryHSL };
darkerHSL.l = Math.max(darkerHSL.l - 0.3, 0.1);
const complementaryHSL = this.adjustHue(primaryHSL, 180);
complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1);
complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2);
return {
primary: this.rgbToHex(primary),
lighter: this.rgbToHex(this.hslToRgb(lighterHSL)),
darker: this.rgbToHex(this.hslToRgb(darkerHSL)),
complementary: this.rgbToHex(this.hslToRgb(complementaryHSL))
};
} else {
// NOTE: Light themes look bad in general with this system.
const lighterHSL = { ...primaryHSL };
lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0);
lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95);
const darkerHSL = { ...primaryHSL };
darkerHSL.s = Math.max(darkerHSL.s - 0.1, 0);
darkerHSL.l = Math.min(darkerHSL.l + 0.3, 0.9);
const complementaryHSL = this.adjustHue(primaryHSL, 180);
complementaryHSL.s = Math.max(complementaryHSL.s - 0.2, 0);
complementaryHSL.l = Math.min(complementaryHSL.l + 0.4, 0.9);
return {
primary: this.rgbToHex(primary),
lighter: this.rgbToHex(this.hslToRgb(lighterHSL)),
darker: this.rgbToHex(this.hslToRgb(darkerHSL)),
complementary: this.rgbToHex(this.hslToRgb(complementaryHSL))
};
}
}
private hexToRgb(hex: string): RGB {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
private rgbToHex(rgb: RGB): string {
return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`;
}
private rgbToHsl(rgb: RGB): { h: number; s: number; l: number } {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h, s, l };
}
private hslToRgb(hsl: { h: number; s: number; l: number }): RGB {
const { h, s, l } = hsl;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
private adjustHue(hsl: { h: number; s: number; l: number }, amount: number): { h: number; s: number; l: number } {
return {
h: (hsl.h + amount / 360) % 1,
s: hsl.s,
l: hsl.l
};
}
private calculateComplementaryRgb(rgb: RGB): RGB {
const hsl = this.rgbToHsl(rgb);
const complementaryHsl = this.adjustHue(hsl, 180);
return this.hslToRgb(complementaryHsl);
}
private rgbaToString(color: RGBAColor): string {
return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
}
private getCssVariable(variableName: string): string {
return getComputedStyle(this.document.body).getPropertyValue(variableName).trim();
}
private isDarkTheme(): boolean {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim().toLowerCase() === 'dark';
}
private injectStyleElement(id: string, styles: string) {
let styleElement = this.document.getElementById(id);
if (!styleElement) {
styleElement = this.document.createElement('style');
styleElement.id = id;
this.document.head.appendChild(styleElement);
}
styleElement.textContent = styles;
}
private unsetPageColorOverrides() {
Array.from(this.document.head.children).filter(el => el.tagName === 'STYLE' && el.id.toLowerCase() === colorScapeSelector).forEach(c => this.document.head.removeChild(c));
}
}

View File

@ -33,11 +33,11 @@ export class DeviceService {
}
createDevice(name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse);
return this.httpClient.post<Device>(this.baseUrl + 'device/create', {name, platform, emailAddress});
}
updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse);
return this.httpClient.post<Device>(this.baseUrl + 'device/update', {id, name, platform, emailAddress});
}
deleteDevice(id: number) {

View File

@ -20,8 +20,8 @@ export class JumpbarService {
return '';
}
getResumePosition(key: string) {
if (this.resumeScroll.hasOwnProperty(key)) return this.resumeScroll[key];
getResumePosition(url: string) {
if (this.resumeScroll.hasOwnProperty(url)) return this.resumeScroll[url];
return 0;
}
@ -29,8 +29,8 @@ export class JumpbarService {
this.resumeKeys[key] = value;
}
saveScrollOffset(key: string, value: number) {
this.resumeScroll[key] = value;
saveResumePosition(url: string, value: number) {
this.resumeScroll[url] = value;
}
generateJumpBar(jumpBarKeys: Array<JumpKey>, currentSize: number) {
@ -93,10 +93,10 @@ export class JumpbarService {
}
/**
*
*
* @param data An array of objects
* @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey
* @returns
* @returns
*/
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {};

View File

@ -1,18 +1,25 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { ReplaySubject, take } from 'rxjs';
import {DestroyRef, inject, Inject, Injectable, OnDestroy, Renderer2, RendererFactory2} from '@angular/core';
import {filter, ReplaySubject, Subject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
import {AccountService} from "./account.service";
import {tap} from "rxjs/operators";
import {map, tap} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
})
export class NavService {
private readonly accountService = inject(AccountService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
public localStorageSideNavKey = 'kavita--sidenav--expanded';
private navbarVisibleSource = new ReplaySubject<boolean>(1);
@ -33,10 +40,22 @@ export class NavService {
*/
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
usePreferenceSideNav$ = this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
map((evt) => {
const event = (evt as NavigationEnd);
const url = event.urlAfterRedirects || event.url;
return (
/\/admin\/dashboard(#.*)?/.test(url) || /\/preferences(\/[^\/]+|#.*)?/.test(url) || /\/settings(\/[^\/]+|#.*)?/.test(url)
);
}),
takeUntilDestroyed(this.destroyRef),
);
private renderer: Renderer2;
baseUrl = environment.apiUrl;
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient, private accountService: AccountService) {
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) {
this.renderer = rendererFactory.createRenderer(null, null);
// To avoid flashing, let's check if we are authenticated before we show
@ -79,9 +98,9 @@ export class NavService {
* Shows the top nav bar. This should be visible on all pages except the reader.
*/
showNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px');
this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - 56px)');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - 56px)');
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', 'var(--nav-offset)');
this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.navbarVisibleSource.next(true);
}
@ -117,4 +136,9 @@ export class NavService {
localStorage.setItem(this.localStorageSideNavKey, newVal + '');
});
}
collapseSideNav(state: boolean) {
this.sideNavCollapseSource.next(state);
localStorage.setItem(this.localStorageSideNavKey, state + '');
}
}

View File

@ -1,9 +1,17 @@
import {DOCUMENT} from '@angular/common';
import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
import {
DestroyRef,
inject,
Inject,
Injectable,
Renderer2,
RendererFactory2,
SecurityContext
} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
import {ToastrService} from 'ngx-toastr';
import {map, ReplaySubject, take} from 'rxjs';
import {filter, map, ReplaySubject, take, tap} from 'rxjs';
import {environment} from 'src/environments/environment';
import {ConfirmService} from '../shared/confirm.service';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
@ -15,7 +23,9 @@ import {translate} from "@ngneat/transloco";
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
import {NgxFileDropEntry} from "ngx-file-drop";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {NavigationEnd, Router} from "@angular/router";
import {ColorscapeService} from "./colorscape.service";
import {ColorScape} from "../_models/theme/colorscape";
@Injectable({
providedIn: 'root'
@ -23,6 +33,8 @@ import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"
export class ThemeService {
private readonly destroyRef = inject(DestroyRef);
private readonly colorTransitionService = inject(ColorscapeService);
public defaultTheme: string = 'dark';
public defaultBookTheme: string = 'Dark';
@ -42,9 +54,16 @@ export class ThemeService {
constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient,
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) {
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService,
private router: Router) {
this.renderer = rendererFactory.createRenderer(null, null);
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe(() => {
this.setColorScape('');
});
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
if (message.event === EVENTS.NotificationProgress) {
@ -90,21 +109,21 @@ export class ThemeService {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
}
/**
* --theme-color from theme. Updates the meta tag
* @returns
*/
getThemeColor() {
return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim();
}
/**
* --theme-color from theme. Updates the meta tag
* @returns
*/
getThemeColor() {
return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim();
}
/**
* --msapplication-TileColor from theme. Updates the meta tag
* @returns
*/
getTileColor() {
return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim();
}
/**
* --msapplication-TileColor from theme. Updates the meta tag
* @returns
*/
getTileColor() {
return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim();
}
getCssVariable(variable: string) {
return getComputedStyle(this.document.body).getPropertyValue(variable).trim();
@ -166,6 +185,26 @@ export class ThemeService {
}
/**
* Set's the background color from a single primary color.
* @param primaryColor
* @param complementaryColor
*/
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
this.colorTransitionService.setColorScape(primaryColor, complementaryColor);
}
/**
* Trigger a request to get the colors for a given entity and apply them
* @param entity
* @param id
*/
refreshColorScape(entity: 'series' | 'volume' | 'chapter', id: number) {
return this.httpClient.get<ColorScape>(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => {
this.setColorScape(cs.primary || '', cs.secondary);
}));
}
/**
* Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body
* @param themeName
@ -187,7 +226,6 @@ export class ThemeService {
const styleElem = this.document.createElement('style');
styleElem.id = 'theme-' + theme.name;
styleElem.appendChild(this.document.createTextNode(content));
this.renderer.appendChild(this.document.head, styleElem);
// Check if the theme has --theme-color and apply it to meta tag
@ -238,6 +276,4 @@ export class ThemeService {
private unsetBookThemes() {
Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c));
}
}

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'actionable'">
<ng-container *ngIf="actions.length > 0">
@if (actions.length > 0) {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
@ -8,33 +8,33 @@
</div>
</div>
<ng-template #submenu let-list="list">
<ng-container *ngFor="let action of list">
@for(action of list; track action.id) {
<!-- Non Submenu items -->
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined ; else submenuDropdown">
<ng-container *ngIf="action.dynamicList !== undefined && (action.dynamicList | async | dynamicList) as dList; else justItem">
<ng-container *ngFor="let dynamicItem of dList">
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
</ng-container>
</ng-container>
<ng-template #justItem>
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
</ng-template>
</ng-container>
<ng-template #submenuDropdown>
<!-- Submenu items -->
<ng-container *ngIf="shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)">
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left" (click)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
<button *ngIf="willRenderAction(action)" id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
}
} @else if (willRenderAction(action)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right-top"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)">
@if (willRenderAction(action)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div>
</ng-container>
</ng-template>
</ng-container>
}
}
}
</ng-template>
</ng-container>
}
</ng-container>

View File

@ -26,3 +26,9 @@
float: right;
padding: var(--bs-dropdown-item-padding-y) 0;
}
// Robbie added this but it broke most of the uses
//.dropdown-toggle {
// padding-top: 0;
// padding-bottom: 0;
//}

View File

@ -11,7 +11,7 @@ import {
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {CommonModule} from "@angular/common";
import {AsyncPipe, CommonModule, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@ngneat/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -19,7 +19,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-card-actionables',
standalone: true,
imports: [CommonModule, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective],
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -1,5 +1,5 @@
.profile-image {
font-size: 2rem;
font-size: 1.2rem;
padding: 20px;
}

View File

@ -21,12 +21,9 @@
}
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
<table class="table table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="createdUtc" (sort)="updateSort($event)">
{{t('created-header')}}
</th>
<th scope="col" sortable="lastModifiedUtc" (sort)="updateSort($event)" direction="desc">
{{t('last-modified-header')}}
</th>
@ -45,66 +42,62 @@
</tr>
</thead>
<tbody>
@if (events.length === 0) {
<tr>
<td colspan="6">{{t('no-data')}}</td>
</tr>
}
<tr *ngFor="let item of events; let idx = index;">
<td>
{{item.createdUtc | utcToLocalTime | defaultValue}}
</td>
<td>
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
@for(item of events; track item; let idx = $index) {
<tr>
<td>
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
</td>
<td>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</td>
<td>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
} @empty {
<tr><td colspan="6" style="text-align: center;">{{t('no-data')}}</td></tr>
}
</tbody>
</table>
</ng-container>

View File

@ -4,7 +4,7 @@ import {CommonModule} from '@angular/common';
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe";
import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
import {debounceTime, take} from "rxjs/operators";

View File

@ -14,12 +14,8 @@ $breadcrumb-divider: quote(">");
border: 1px solid #ced4da;
}
.table {
background-color: lightgrey;
}
.disabled {
color: lightgrey !important;
cursor: not-allowed !important;
background-color: var(--error-color);
}
}

View File

@ -3,10 +3,10 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import {NgFor, NgIf} from '@angular/common';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TranslocoDirective} from "@ngneat/transloco";
import {SelectionModel} from "../../../typeahead/_models/selection-model";
@Component({
selector: 'app-library-access-modal',

View File

@ -1,3 +1,4 @@
export enum CoverImageSize {
Default = 1,
Medium = 2,
@ -5,10 +6,6 @@ export enum CoverImageSize {
XLarge = 4
}
export const CoverImageSizes =
[
{value: CoverImageSize.Default, title: 'cover-image-size.default'},
{value: CoverImageSize.Medium, title: 'cover-image-size.medium'},
{value: CoverImageSize.Large, title: 'cover-image-size.large'},
{value: CoverImageSize.XLarge, title: 'cover-image-size.xlarge'}
];
export const allCoverImageSizes = Object.keys(CoverImageSize)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as CoverImageSize[];

View File

@ -4,4 +4,6 @@ export enum EncodeFormat {
AVIF = 2
}
export const EncodeFormats = [{value: EncodeFormat.PNG, title: 'PNG'}, {value: EncodeFormat.WebP, title: 'WebP'}, {value: EncodeFormat.AVIF, title: 'AVIF'}];
export const allEncodeFormats = Object.keys(EncodeFormat)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as EncodeFormat[];

View File

@ -3,4 +3,5 @@ export interface KavitaMediaError {
filePath: string;
comment: string;
details: string;
createdUtc: string;
}

View File

@ -1,48 +0,0 @@
<ng-container *transloco="let t; read: 'admin-dashboard'">
<app-side-nav-companion-bar>
<h2 title>
{{t('title')}}
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class=tab>
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ t(tab.title) }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === TabID.General">
<app-manage-settings></app-manage-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Email">
<app-manage-email-settings></app-manage-email-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Media">
<app-manage-media-settings></app-manage-media-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Users">
<app-manage-users></app-manage-users>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Libraries">
<app-manage-library></app-manage-library>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.System">
<app-manage-system></app-manage-system>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Statistics">
<app-server-stats></app-server-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Tasks">
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
<app-license></app-license>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
</div>
</ng-container>

View File

@ -1,10 +0,0 @@
.container {
padding-top: 10px;
}
.tab:last-child > a {
&.active, &::before {
background-color: #FFBA15;
}
}

View File

@ -1,88 +0,0 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {Title} from '@angular/platform-browser';
import {NavService} from '../../_services/nav.service';
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {LicenseComponent} from '../license/license.component';
import {ManageTasksSettingsComponent} from '../manage-tasks-settings/manage-tasks-settings.component';
import {ServerStatsComponent} from '../../statistics/_components/server-stats/server-stats.component';
import {ManageSystemComponent} from '../manage-system/manage-system.component';
import {ManageLogsComponent} from '../manage-logs/manage-logs.component';
import {ManageLibraryComponent} from '../manage-library/manage-library.component';
import {ManageUsersComponent} from '../manage-users/manage-users.component';
import {ManageMediaSettingsComponent} from '../manage-media-settings/manage-media-settings.component';
import {ManageEmailSettingsComponent} from '../manage-email-settings/manage-email-settings.component';
import {ManageSettingsComponent} from '../manage-settings/manage-settings.component';
import {NgFor, NgIf} from '@angular/common';
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {WikiLink} from "../../_models/wiki";
enum TabID {
General = '',
Email = 'email',
Media = 'media',
Users = 'users',
Libraries = 'libraries',
System = 'system',
Tasks = 'tasks',
Logs = 'logs',
Statistics = 'statistics',
KavitaPlus = 'kavitaplus'
}
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink,
NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent,
ManageUsersComponent, ManageLibraryComponent, ManageSystemComponent, ServerStatsComponent,
ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe, TranslocoDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly route = inject(ActivatedRoute);
protected readonly navService = inject(NavService);
private readonly titleService = inject(Title);
protected readonly TabID = TabID;
protected readonly WikiLink = WikiLink;
tabs: Array<{title: string, fragment: string}> = [
{title: 'general-tab', fragment: TabID.General},
{title: 'users-tab', fragment: TabID.Users},
{title: 'libraries-tab', fragment: TabID.Libraries},
{title: 'media-tab', fragment: TabID.Media},
{title: 'email-tab', fragment: TabID.Email},
{title: 'tasks-tab', fragment: TabID.Tasks},
{title: 'statistics-tab', fragment: TabID.Statistics},
{title: 'system-tab', fragment: TabID.System},
{title: 'kavita+-tab', fragment: TabID.KavitaPlus},
];
active = this.tabs[0];
constructor() {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {
this.active = tab[0];
} else {
this.active = this.tabs[0]; // Default to first tab
}
this.cdRef.markForCheck();
});
}
ngOnInit() {
this.titleService.setTitle('Kavita - ' + translate('admin-dashboard.title'));
}
}

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'edit-user'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h4>
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
@ -10,7 +10,7 @@
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="col-md-6 col-sm-12 pe-4">
<div class="mb-3">
<label for="username" class="form-label">{{t('username')}}</label>
<input id="username" class="form-control" formControlName="username" type="text"
@ -45,7 +45,7 @@
</div>
<div class="row g-0">
<div class="col-md-6">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>
@ -54,7 +54,7 @@
</div>
</div>
<div class="row g-0">
<div class="row g-0 mt-3">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>

View File

@ -1,15 +1,15 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
import { Library } from 'src/app/_models/library/library';
import { Member } from 'src/app/_models/auth/member';
import { AccountService } from 'src/app/_services/account.service';
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {AccountService} from 'src/app/_services/account.service';
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
import {RoleSelectorComponent} from '../role-selector/role-selector.component';
import {NgIf} from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
@ -19,10 +19,15 @@ const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective]
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditUserComponent implements OnInit {
private readonly accountService = inject(AccountService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly modal = inject(NgbActiveModal);
@Input({required: true}) member!: Member;
selectedRoles: Array<string> = [];
@ -39,7 +44,7 @@ export class EditUserComponent implements OnInit {
public get password() { return this.userForm.get('password'); }
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
ngOnInit(): void {
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email]));
@ -47,18 +52,22 @@ export class EditUserComponent implements OnInit {
this.userForm.get('email')?.disable();
this.selectedRestriction = this.member.ageRestriction;
this.cdRef.markForCheck();
}
updateRoleSelection(roles: Array<string>) {
this.selectedRoles = roles;
this.cdRef.markForCheck();
}
updateRestrictionSelection(restriction: AgeRestriction) {
this.selectedRestriction = restriction;
this.cdRef.markForCheck();
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
this.cdRef.markForCheck();
}
close() {
@ -71,6 +80,7 @@ export class EditUserComponent implements OnInit {
model.roles = this.selectedRoles;
model.libraries = this.selectedLibraries;
model.ageRestriction = this.selectedRestriction;
this.accountService.update(model).subscribe(() => {
this.modal.close(true);
});

View File

@ -24,7 +24,7 @@
</div>
<div class="row g-0">
<div class="col-md-6">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>

View File

@ -1,22 +1,42 @@
<ng-container *transloco="let t; read: 'library-selector'">
<h4>{{t('title')}}</h4>
<div class="list-group" *ngIf="!isLoading">
<div class="form-check" *ngIf="allLibraries.length > 0">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
<div class="d-flex justify-content-between">
<div class="col-auto">
<h4>{{t('title')}}</h4>
</div>
<div class="col-auto">
@if(!isLoading && allLibraries.length > 0) {
<span class="form-check float-end">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
{{t('no-data')}}
</li>
</ul>
</div>
@if (isLoading) {
<app-loading [loading]="isLoading"></app-loading>
} @else {
<div class="list-group">
<ul class="ps-0">
@for (library of allLibraries; track library.name; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</div>
}
</ng-container>

View File

@ -1,21 +1,34 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { Library } from 'src/app/_models/library/library';
import { Member } from 'src/app/_models/auth/member';
import { LibraryService } from 'src/app/_services/library.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import { NgIf, NgFor } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {TranslocoDirective} from "@ngneat/transloco";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {SelectionModel} from "../../typeahead/_models/selection-model";
@Component({
selector: 'app-library-selector',
templateUrl: './library-selector.component.html',
styleUrls: ['./library-selector.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor, TranslocoDirective]
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, LoadingComponent],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibrarySelectorComponent implements OnInit {
private readonly libraryService = inject(LibraryService);
private readonly cdRef = inject(ChangeDetectorRef);
@Input() member: Member | undefined;
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
@ -29,7 +42,6 @@ export class LibrarySelectorComponent implements OnInit {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(private libraryService: LibraryService, private fb: FormBuilder) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {
@ -51,12 +63,14 @@ export class LibrarySelectorComponent implements OnInit {
this.selectAll = this.selections.selected().length === this.allLibraries.length;
this.selected.emit(this.selections.selected());
}
this.cdRef.markForCheck();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
this.selected.emit(this.selections.selected());
this.cdRef.markForCheck();
}
handleSelection(item: Library) {
@ -68,6 +82,7 @@ export class LibrarySelectorComponent implements OnInit {
this.selectAll = true;
}
this.cdRef.markForCheck();
this.selected.emit(this.selections.selected());
}

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