Smart Collection UI Changes (#3332)

This commit is contained in:
Joe Milazzo 2024-11-04 11:16:17 -06:00 committed by GitHub
parent dcd281c5c3
commit 9e299d08b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 431 additions and 189 deletions

View File

@ -58,16 +58,30 @@ public class ChapterController : BaseApiController
if (chapter == null) if (chapter == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var vol = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters);
_unitOfWork.ChapterRepository.Remove(chapter); if (vol == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
if (await _unitOfWork.CommitAsync()) // If there is only 1 chapter within the volume, then we need to remove the volume
var needToRemoveVolume = vol.Chapters.Count == 1;
if (needToRemoveVolume)
{ {
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); _unitOfWork.VolumeRepository.Remove(vol);
return Ok(true); }
else
{
_unitOfWork.ChapterRepository.Remove(chapter);
} }
return Ok(false);
if (!await _unitOfWork.CommitAsync()) return Ok(false);
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false);
if (needToRemoveVolume)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false);
}
return Ok(true);
} }
/// <summary> /// <summary>

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Metadata; using API.DTOs.Metadata;
@ -33,18 +34,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
/// <param name="libraryIds">String separated libraryIds or null for all genres</param> /// <param name="libraryIds">String separated libraryIds or null for all genres</param>
/// <returns></returns> /// <returns></returns>
[HttpGet("genres")] [HttpGet("genres")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds) public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None)
{ {
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
// NOTE: libraryIds isn't hooked up in the frontend return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
if (ids is {Count: > 0})
{
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids));
}
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId()));
} }
/// <summary> /// <summary>

View File

@ -163,6 +163,7 @@ public class SettingsController : BaseApiController
bookmarkDirectory = _directoryService.BookmarkDirectory; bookmarkDirectory = _directoryService.BookmarkDirectory;
} }
var updateTask = false;
foreach (var setting in currentSettings) foreach (var setting in currentSettings)
{ {
if (setting.Key == ServerSettingKey.OnDeckProgressDays && if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
@ -204,7 +205,7 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }
UpdateSchedulingSettings(setting, updateSettingsDto); updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
UpdateEmailSettings(setting, updateSettingsDto); UpdateEmailSettings(setting, updateSettingsDto);
@ -348,6 +349,11 @@ public class SettingsController : BaseApiController
UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory);
} }
if (updateTask)
{
BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks());
}
if (updateSettingsDto.EnableFolderWatching) if (updateSettingsDto.EnableFolderWatching)
{ {
BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching());
@ -379,25 +385,31 @@ public class SettingsController : BaseApiController
_directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory);
} }
private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
{ {
if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value)
{ {
//if (updateSettingsDto.TotalBackup)
setting.Value = updateSettingsDto.TaskBackup; setting.Value = updateSettingsDto.TaskBackup;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
return true;
} }
if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
{ {
setting.Value = updateSettingsDto.TaskScan; setting.Value = updateSettingsDto.TaskScan;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
return true;
} }
if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value)
{ {
setting.Value = updateSettingsDto.TaskCleanup; setting.Value = updateSettingsDto.TaskCleanup;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
return true;
} }
return false;
} }
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)

View File

@ -21,7 +21,7 @@ public interface IGenreRepository
Task<IList<Genre>> GetAllGenresAsync(); Task<IList<Genre>> GetAllGenresAsync();
Task<IList<Genre>> GetAllGenresByNamesAsync(IEnumerable<string> normalizedNames); Task<IList<Genre>> GetAllGenresByNamesAsync(IEnumerable<string> normalizedNames);
Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false);
Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null); Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null, QueryContext context = QueryContext.None);
Task<int> GetCountAsync(); Task<int> GetCountAsync();
Task<GenreTagDto> GetRandomGenre(); Task<GenreTagDto> GetRandomGenre();
Task<GenreTagDto> GetGenreById(int id); Task<GenreTagDto> GetGenreById(int id);
@ -115,10 +115,10 @@ public class GenreRepository : IGenreRepository
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="libraryIds"></param> /// <param name="libraryIds"></param>
/// <returns></returns> /// <returns></returns>
public async Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null) public async Task<IList<GenreTagDto>> GetAllGenreDtosForLibrariesAsync(int userId, IList<int>? libraryIds = null, QueryContext context = QueryContext.None)
{ {
var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); var userLibs = await _context.Library.GetUserLibraries(userId, context).ToListAsync();
if (libraryIds is {Count: > 0}) if (libraryIds is {Count: > 0})
{ {

View File

@ -65,6 +65,7 @@ public enum QueryContext
{ {
None = 1, None = 1,
Search = 2, Search = 2,
[Obsolete("Use Dashboard")]
Recommended = 3, Recommended = 3,
Dashboard = 4, Dashboard = 4,
} }
@ -1509,7 +1510,7 @@ public class SeriesRepository : ISeriesRepository
public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams)
{ {
var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard)
.Where(id => libraryId == 0 || id == libraryId); .Where(id => libraryId == 0 || id == libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);

View File

@ -12,6 +12,7 @@ using API.Services.Tasks;
using API.Services.Tasks.Metadata; using API.Services.Tasks.Metadata;
using API.SignalR; using API.SignalR;
using Hangfire; using Hangfire;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services; namespace API.Services;
@ -121,23 +122,32 @@ public class TaskScheduler : ITaskScheduler
public async Task ScheduleTasks() public async Task ScheduleTasks()
{ {
_logger.LogInformation("Scheduling reoccurring tasks"); _logger.LogInformation("Scheduling reoccurring tasks");
var nonCronOptions = new List<string>(["disabled", "daily", "weekly"]);
var setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Value; var setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Value;
if (setting != null) if (setting == null || (!nonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting)))
{
_logger.LogError("Scan Task has invalid cron, defaulting to Daily");
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
Cron.Daily, RecurringJobOptions);
}
else
{ {
var scanLibrarySetting = setting; var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
} }
else
{
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
Cron.Daily, RecurringJobOptions);
}
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value;
if (setting != null) if (setting == null || (!nonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting)))
{
_logger.LogError("Backup Task has invalid cron, defaulting to Daily");
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(),
Cron.Weekly, RecurringJobOptions);
}
else
{ {
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting); _logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
var schedule = CronConverter.ConvertToCronNotation(setting); var schedule = CronConverter.ConvertToCronNotation(setting);
@ -149,16 +159,21 @@ public class TaskScheduler : ITaskScheduler
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(),
() => schedule, RecurringJobOptions); () => schedule, RecurringJobOptions);
} }
else
{
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(),
Cron.Weekly, RecurringJobOptions);
}
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value; setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value;
if (setting == null || (!nonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting)))
{
_logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting);
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(),
CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
}
else
{
_logger.LogError("Cleanup Task has invalid cron, defaulting to Daily");
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(),
Cron.Daily, RecurringJobOptions);
}
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(),
Cron.Daily, RecurringJobOptions); Cron.Daily, RecurringJobOptions);

View File

@ -464,7 +464,6 @@
"version": "18.2.9", "version": "18.2.9",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz",
"integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==",
"dev": true,
"dependencies": { "dependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.2",
"@jridgewell/sourcemap-codec": "^1.4.14", "@jridgewell/sourcemap-codec": "^1.4.14",
@ -492,7 +491,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"dependencies": { "dependencies": {
"readdirp": "^4.0.1" "readdirp": "^4.0.1"
}, },
@ -507,7 +505,6 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"engines": { "engines": {
"node": ">= 14.16.0" "node": ">= 14.16.0"
}, },
@ -4010,8 +4007,7 @@
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
"dev": true
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "8.3.6", "version": "8.3.6",
@ -4518,7 +4514,6 @@
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"iconv-lite": "^0.6.2" "iconv-lite": "^0.6.2"
@ -4528,7 +4523,6 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@ -7470,8 +7464,7 @@
"node_modules/reflect-metadata": { "node_modules/reflect-metadata": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
"dev": true
}, },
"node_modules/replace-in-file": { "node_modules/replace-in-file": {
"version": "7.1.0", "version": "7.1.0",
@ -7742,7 +7735,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true "devOptional": true
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.77.6", "version": "1.77.6",
@ -7776,7 +7769,6 @@
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"dev": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@ -8331,7 +8323,6 @@
"version": "5.5.4", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -16,6 +16,9 @@ export interface UserCollection {
source: ScrobbleProvider; source: ScrobbleProvider;
sourceUrl: string | null; sourceUrl: string | null;
totalSourceCount: number; totalSourceCount: number;
/**
* HTML anchors separated by <br/>
*/
missingSeriesFromSource: string | null; missingSeriesFromSource: string | null;
ageRating: AgeRating; ageRating: AgeRating;
itemCount: number; itemCount: number;

View File

@ -2,7 +2,6 @@ import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; import {BehaviorSubject, filter, take, tap, timer} from 'rxjs';
import {NavigationEnd, Router} from "@angular/router"; import {NavigationEnd, Router} from "@angular/router";
import {debounceTime} from "rxjs/operators";
interface ColorSpace { interface ColorSpace {
primary: string; primary: string;
@ -18,18 +17,9 @@ interface ColorSpaceRGBA {
complementary: RGBAColor; complementary: RGBAColor;
} }
interface RGBAColor { interface RGBAColor {r: number;g: number;b: number;a: number;}
r: number; interface RGB { r: number;g: number; b: number; }
g: number; interface HSL { h: number; s: number; l: number; }
b: number;
a: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
const colorScapeSelector = 'colorscape'; const colorScapeSelector = 'colorscape';
@ -49,7 +39,6 @@ export class ColorscapeService {
private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace
constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) {
this.router.events.pipe( this.router.events.pipe(
filter(event => event instanceof NavigationEnd), filter(event => event instanceof NavigationEnd),
tap(() => this.checkAndResetColorscapeAfterDelay()) tap(() => this.checkAndResetColorscapeAfterDelay())
@ -100,9 +89,17 @@ export class ColorscapeService {
this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor});
return; return;
} }
this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor});
// TODO: Check if there is a secondary color and if the color is a strong contrast (opposite on color wheel) to primary
// If we have a STRONG primary and secondary, generate LEFT/RIGHT orientation
// If we have only one color, randomize the position of the primary
// If we have 2 colors, but their contrast isn't STRONG, then use diagonal for Primary and Secondary
const newColors: ColorSpace = primaryColor ? const newColors: ColorSpace = primaryColor ?
this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) :
this.defaultColors(); this.defaultColors();
@ -144,7 +141,8 @@ export class ColorscapeService {
}; };
} }
private parseColorToRGBA(color: string): RGBAColor { private parseColorToRGBA(color: string) {
if (color.startsWith('#')) { if (color.startsWith('#')) {
return this.hexToRGBA(color); return this.hexToRGBA(color);
} else if (color.startsWith('rgb')) { } else if (color.startsWith('rgb')) {
@ -246,12 +244,19 @@ export class ColorscapeService {
requestAnimationFrame(animate); requestAnimationFrame(animate);
} }
private easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor { private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor {
const easedProgress = this.easeInOutCubic(progress);
return { return {
r: Math.round(color1.r + (color2.r - color1.r) * progress), r: Math.round(color1.r + (color2.r - color1.r) * easedProgress),
g: Math.round(color1.g + (color2.g - color1.g) * progress), g: Math.round(color1.g + (color2.g - color1.g) * easedProgress),
b: Math.round(color1.b + (color2.b - color1.b) * progress), b: Math.round(color1.b + (color2.b - color1.b) * easedProgress),
a: color1.a + (color2.a - color1.a) * progress a: color1.a + (color2.a - color1.a) * easedProgress
}; };
} }
@ -300,7 +305,7 @@ export class ColorscapeService {
}); });
} }
private calculateLightThemeDarkColors(primaryHSL: { h: number; s: number; l: number }, primary: RGB) { private calculateLightThemeDarkColors(primaryHSL: HSL, primary: RGB) {
const lighterHSL = {...primaryHSL}; const lighterHSL = {...primaryHSL};
lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0); lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0);
lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95); lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95);
@ -321,7 +326,7 @@ export class ColorscapeService {
}; };
} }
private calculateDarkThemeColors(secondaryHSL: { h: number; s: number; l: number }, primaryHSL: { private calculateDarkThemeColors(secondaryHSL: HSL, primaryHSL: {
h: number; h: number;
s: number; s: number;
l: number l: number
@ -406,7 +411,7 @@ export class ColorscapeService {
return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`; 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 } { private rgbToHsl(rgb: RGB): HSL {
const r = rgb.r / 255; const r = rgb.r / 255;
const g = rgb.g / 255; const g = rgb.g / 255;
const b = rgb.b / 255; const b = rgb.b / 255;
@ -430,7 +435,7 @@ export class ColorscapeService {
return { h, s, l }; return { h, s, l };
} }
private hslToRgb(hsl: { h: number; s: number; l: number }): RGB { private hslToRgb(hsl: HSL): RGB {
const { h, s, l } = hsl; const { h, s, l } = hsl;
let r, g, b; let r, g, b;
@ -460,7 +465,7 @@ export class ColorscapeService {
}; };
} }
private adjustHue(hsl: { h: number; s: number; l: number }, amount: number): { h: number; s: number; l: number } { private adjustHue(hsl: HSL, amount: number): HSL {
return { return {
h: (hsl.h + amount / 360) % 1, h: (hsl.h + amount / 360) % 1,
s: hsl.s, s: hsl.s,

View File

@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {tap} from 'rxjs/operators'; import {tap} from 'rxjs/operators';
import {of} from 'rxjs'; import {of} from 'rxjs';
@ -19,6 +19,7 @@ import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
import {LibraryType} from "../_models/library/library"; import {LibraryType} from "../_models/library/library";
import {IHasCast} from "../_models/common/i-has-cast"; import {IHasCast} from "../_models/common/i-has-cast";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {QueryContext} from "../_models/metadata/v2/query-context";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -62,11 +63,14 @@ export class MetadataService {
return this.httpClient.get<Array<Tag>>(this.baseUrl + method); return this.httpClient.get<Array<Tag>>(this.baseUrl + method);
} }
getAllGenres(libraries?: Array<number>) { getAllGenres(libraries?: Array<number>, context: QueryContext = QueryContext.None) {
let method = 'metadata/genres' let method = 'metadata/genres'
if (libraries != undefined && libraries.length > 0) { if (libraries != undefined && libraries.length > 0) {
method += '?libraryIds=' + libraries.join(','); method += '?libraryIds=' + libraries.join(',') + '&context=' + context;
} else {
method += '?context=' + context;
} }
return this.httpClient.get<Array<Genre>>(this.baseUrl + method); return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
} }

View File

@ -0,0 +1,40 @@
<ng-container *transloco="let t">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{collection.title}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
<div class="mb-3">
<app-setting-item [title]="t('edit-collection-tags.last-sync-title')" [showEdit]="false" [canEdit]="false"
[subtitle]="t('edit-collection-tags.last-sync-tooltip')">
<ng-template #view>
{{collection.lastSyncUtc | utcToLocalTime | date:'shortDate' | defaultDate}}
</ng-template>
</app-setting-item>
</div>
<div class="mb-3">
<app-setting-item [title]="t('edit-collection-tags.series-tab')" [showEdit]="false" [canEdit]="false">
<ng-template #titleExtra>
<span class="badge rounded-pill text-bg-primary ms-1" style="font-size: 11px">{{collection.totalSourceCount - series.length}} / {{collection.totalSourceCount | number}}</span>
</ng-template>
<ng-template #view>
@if(collection.missingSeriesFromSource) {
<p [innerHTML]="collection.missingSeriesFromSource | safeHtml"></p>
}
@for(s of series; track s.name) {
<div class="row g-0">
<del><a [routerLink]="['library', s.libraryId, 'series', s.id]" target="_blank" class="strike">{{s.name}}</a></del>
</div>
}
</ng-template>
</app-setting-item>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,5 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,58 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {NgbActiveOffcanvas, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {UserCollection} from "../../_models/collection-tag";
import {ImageComponent} from "../../shared/image/image.component";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
import {DatePipe, DecimalPipe, NgOptimizedImage} from "@angular/common";
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
import {PublicationStatusPipe} from "../../_pipes/publication-status.pipe";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {Series} from "../../_models/series";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {RouterLink} from "@angular/router";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
@Component({
selector: 'app-smart-collection-drawer',
standalone: true,
imports: [
ImageComponent,
LoadingComponent,
MetadataDetailComponent,
NgOptimizedImage,
NgbTooltip,
ProviderImagePipe,
PublicationStatusPipe,
ReadMoreComponent,
TranslocoDirective,
SafeHtmlPipe,
RouterLink,
DatePipe,
DefaultDatePipe,
UtcToLocalTimePipe,
SettingItemComponent,
DecimalPipe
],
templateUrl: './smart-collection-drawer.component.html',
styleUrl: './smart-collection-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SmartCollectionDrawerComponent implements OnInit {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) collection!: UserCollection;
@Input({required: true}) series: Series[] = [];
ngOnInit() {
}
close() {
this.activeOffcanvas.close();
}
}

View File

@ -8,7 +8,11 @@
@if (settingsForm.get('taskScan'); as formControl) { @if (settingsForm.get('taskScan'); as formControl) {
<app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')"> <app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')">
<ng-template #view> <ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}})
} @else {
{{t(formControl.value)}} {{t(formControl.value)}}
}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
@ -27,7 +31,7 @@
aria-describedby="task-scan-validations"> aria-describedby="task-scan-validations">
@if (settingsForm.dirty || !settingsForm.untouched) { @if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-scan-validations" class="invalid-feedback"> <div id="task-scan-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskScanCustom')?.errors?.required) { @if(settingsForm.get('taskScanCustom')?.errors?.required) {
<div>{{t('required')}}</div> <div>{{t('required')}}</div>
} }
@ -47,7 +51,11 @@
@if (settingsForm.get('taskBackup'); as formControl) { @if (settingsForm.get('taskBackup'); as formControl) {
<app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')"> <app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')">
<ng-template #view> <ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}})
} @else {
{{t(formControl.value)}} {{t(formControl.value)}}
}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
@ -66,7 +74,7 @@
aria-describedby="task-scan-validations"> aria-describedby="task-scan-validations">
@if (settingsForm.dirty || !settingsForm.untouched) { @if (settingsForm.dirty || !settingsForm.untouched) {
<div id="task-backup-validations" class="invalid-feedback"> <div id="task-backup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskBackupCustom')?.errors?.required) { @if(settingsForm.get('taskBackupCustom')?.errors?.required) {
<div>{{t('required')}}</div> <div>{{t('required')}}</div>
} }
@ -87,7 +95,11 @@
@if (settingsForm.get('taskCleanup'); as formControl) { @if (settingsForm.get('taskCleanup'); as formControl) {
<app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')"> <app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')">
<ng-template #view> <ng-template #view>
@if (formControl.value === customOption) {
{{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}})
} @else {
{{t(formControl.value)}} {{t(formControl.value)}}
}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
@ -105,8 +117,8 @@
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched" [class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
aria-describedby="task-scan-validations"> aria-describedby="task-scan-validations">
@if (settingsForm.dirty || !settingsForm.untouched) { @if (settingsForm.get('taskCleanupCustom')?.invalid) {
<div id="task-cleanup-validations" class="invalid-feedback"> <div id="task-cleanup-validations" class="invalid-feedback" style="display: inline-block">
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) { @if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
<div>{{t('required')}}</div> <div>{{t('required')}}</div>
} }
@ -123,10 +135,6 @@
</div> </div>
</ng-container> </ng-container>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
</div>
<div class="setting-section-break"></div> <div class="setting-section-break"></div>
<h4>{{t('adhoc-tasks-title')}}</h4> <h4>{{t('adhoc-tasks-title')}}</h4>

View File

@ -165,19 +165,25 @@ export class ManageTasksSettingsComponent implements OnInit {
this.validateCronExpression('taskBackupCustom'); this.validateCronExpression('taskBackupCustom');
this.validateCronExpression('taskCleanupCustom'); this.validateCronExpression('taskCleanupCustom');
// Setup individual pipelines to save the changes automatically
// Automatically save settings as we edit them // Automatically save settings as we edit them
this.settingsForm.valueChanges.pipe( this.settingsForm.valueChanges.pipe(
distinctUntilChanged(), distinctUntilChanged(),
debounceTime(100), debounceTime(500),
filter(_ => this.settingsForm.valid), filter(_ => this.isFormValid()),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
// switchMap(_ => {
// // There can be a timing issue between isValidCron API and the form being valid. I currently solved by upping the debounceTime
// }),
switchMap(_ => { switchMap(_ => {
const data = this.packData(); const data = this.packData();
return this.settingsService.updateServerSettings(data); return this.settingsService.updateServerSettings(data);
}), }),
tap(settings => { tap(settings => {
this.serverSettings = settings; this.serverSettings = settings;
this.resetForm();
this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay()); this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}) })
@ -202,6 +208,8 @@ export class ManageTasksSettingsComponent implements OnInit {
} }
} }
// Validate the custom fields for cron expressions // Validate the custom fields for cron expressions
validateCronExpression(controlName: string) { validateCronExpression(controlName: string) {
this.settingsForm.get(controlName)?.valueChanges.pipe( this.settingsForm.get(controlName)?.valueChanges.pipe(
@ -213,12 +221,43 @@ export class ManageTasksSettingsComponent implements OnInit {
} else { } else {
this.settingsForm.get(controlName)?.setErrors({ invalidCron: true }); this.settingsForm.get(controlName)?.setErrors({ invalidCron: true });
} }
this.settingsForm.updateValueAndValidity(); // Ensure form validity reflects changes
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(); ).subscribe();
} }
isFormValid(): boolean {
// Check if the main form is valid
if (!this.settingsForm.valid) {
return false;
}
// List of pairs for main control and corresponding custom control
const customChecks: { mainControl: string; customControl: string }[] = [
{ mainControl: 'taskScan', customControl: 'taskScanCustom' },
{ mainControl: 'taskBackup', customControl: 'taskBackupCustom' },
{ mainControl: 'taskCleanup', customControl: 'taskCleanupCustom' }
];
for (const check of customChecks) {
const mainControlValue = this.settingsForm.get(check.mainControl)?.value;
const customControl = this.settingsForm.get(check.customControl);
// Only validate the custom control if the main control is set to the custom option
if (mainControlValue === this.customOption) {
// Ensure custom control has a value and passes validation
if (customControl?.invalid || !customControl?.value) {
return false; // Form is invalid if custom option is selected but custom control is invalid or empty
}
}
}
return true; // Return true only if both main form and any necessary custom fields are valid
}
resetForm() { resetForm() {
this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan, {onlySelf: true, emitEvent: false}); this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan, {onlySelf: true, emitEvent: false});
@ -265,22 +304,11 @@ export class ManageTasksSettingsComponent implements OnInit {
modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value; modelSettings.taskCleanup = this.settingsForm.get('taskCleanupCustom')?.value;
} }
console.log('modelSettings: ', modelSettings);
return modelSettings; return modelSettings;
} }
async resetToDefaults() {
if (!await this.confirmService.confirm(translate('toasts.confirm-reset-server-settings'))) return;
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success(translate('toasts.server-settings-updated'));
}, (err: any) => {
console.error('error: ', err);
});
}
runAdhoc(task: AdhocTask) { runAdhoc(task: AdhocTask) {
task.api.subscribe((data: any) => { task.api.subscribe((data: any) => {
if (task.successMessage.length > 0) { if (task.successMessage.length > 0) {
@ -290,8 +318,6 @@ export class ManageTasksSettingsComponent implements OnInit {
if (task.successFunction) { if (task.successFunction) {
task.successFunction(data); task.successFunction(data);
} }
}, (err: any) => {
console.error('error: ', err);
}); });
} }

View File

@ -16,7 +16,7 @@
<label for="library-name" class="form-label">{{t('name-label')}}</label> <label for="library-name" class="form-label">{{t('name-label')}}</label>
<input id="library-name" class="form-control" formControlName="title" type="text" <input id="library-name" class="form-control" formControlName="title" type="text"
[class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched"> [class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
@if (collectionTagForm.dirty || collectionTagForm.touched) { @if (collectionTagForm.dirty || !collectionTagForm.untouched) {
<div id="inviteForm-validations" class="invalid-feedback"> <div id="inviteForm-validations" class="invalid-feedback">
@if (collectionTagForm.get('title')?.errors?.required) { @if (collectionTagForm.get('title')?.errors?.required) {
<div>{{t('required-field')}}</div> <div>{{t('required-field')}}</div>

View File

@ -1,5 +1,6 @@
.bulk-select-container { .bulk-select-container {
position: absolute; position: absolute;
width: 100%;
.bulk-select { .bulk-select {
background-color: var(--bulk-selection-bg-color); background-color: var(--bulk-selection-bg-color);

View File

@ -73,9 +73,11 @@
</div> </div>
@if (title.length > 0 || actions.length > 0) { @if (title.length > 0 || actions.length > 0) {
<div class="card-title-container"> <div class="card-title-container">
<span class="card-format"> <span>
@if (showFormat) { @if (showFormat) {
<span class="card-format">
<app-series-format [format]="format"></app-series-format> <app-series-format [format]="format"></app-series-format>
</span>
} }
</span> </span>

View File

@ -108,6 +108,8 @@ export class EntityTitleComponent implements OnInit {
let renderText = ''; let renderText = '';
if (this.titleName !== '' && this.prioritizeTitleName) { if (this.titleName !== '' && this.prioritizeTitleName) {
renderText = this.titleName; renderText = this.titleName;
} else if (this.fallbackToVolume && this.isChapter) { // (his is a single volume on volume detail page
renderText = translate('entity-title.single-volume');
} else if (this.number === this.LooseLeafOrSpecial) { } else if (this.number === this.LooseLeafOrSpecial) {
renderText = ''; renderText = '';
} else { } else {
@ -120,6 +122,8 @@ export class EntityTitleComponent implements OnInit {
let renderText = ''; let renderText = '';
if (this.titleName !== '' && this.prioritizeTitleName) { if (this.titleName !== '' && this.prioritizeTitleName) {
renderText = this.titleName; renderText = this.titleName;
} else if (this.fallbackToVolume && this.isChapter) { // (his is a single volume on volume detail page
renderText = translate('entity-title.single-volume');
} else if (this.number === this.LooseLeafOrSpecial) { } else if (this.number === this.LooseLeafOrSpecial) {
renderText = ''; renderText = '';
} else { } else {

View File

@ -25,6 +25,8 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ExternalSeriesCardComponent { export class ExternalSeriesCardComponent {
private readonly offcanvasService = inject(NgbOffcanvas);
@Input({required: true}) data!: ExternalSeries; @Input({required: true}) data!: ExternalSeries;
/** /**
* When clicking on the series, instead of opening, opens a preview drawer * When clicking on the series, instead of opening, opens a preview drawer
@ -33,8 +35,6 @@ export class ExternalSeriesCardComponent {
@ViewChild('link', {static: false}) link!: ElementRef<HTMLAnchorElement>; @ViewChild('link', {static: false}) link!: ElementRef<HTMLAnchorElement>;
private readonly offcanvasService = inject(NgbOffcanvas);
handleClick() { handleClick() {
if (this.previewOnClick) { if (this.previewOnClick) {
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''}); const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''});

View File

@ -1,22 +1,32 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'collection-detail'"> <ng-container *transloco="let t; read: 'collection-detail'">
<div #companionBar> <div #companionBar>
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> @if (series) {
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<ng-container title> <ng-container title>
<h4 *ngIf="collectionTag !== undefined"> @if (collectionTag) {
{{collectionTag.title}}<span class="ms-1" *ngIf="collectionTag.promoted">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span> <h4>
{{collectionTag.title}}
@if(collectionTag.promoted) {
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
}
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables> <app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
</h4> </h4>
}
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
</ng-container> </ng-container>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
}
</div> </div>
<div class="container-fluid" *ngIf="collectionTag !== undefined"> @if (collectionTag) {
<div class="container-fluid">
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) { @if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block"> <div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image [styles]="{'max-width': '481px'}" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image> <app-image [styles]="{'max-width': '481px'}" [imageUrl]="imageService.getCollectionCoverImage(collectionTag.id)"></app-image>
@if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null @if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) { && series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
<div class="under-image"> <div class="under-image">
@ -33,6 +43,12 @@
<div class="mb-2"> <div class="mb-2">
<app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more> <app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
</div> </div>
@if (collectionTag.source !== ScrobbleProvider.Kavita) {
<div class="mt-2 mb-2">
<button class="btn btn-primary-outline" (click)="openSyncDetailDrawer()">Sync Details</button>
</div>
}
} }
</div> </div>
<hr> <hr>
@ -43,7 +59,8 @@
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations> <app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter" @if (filter) {
<app-card-detail-layout
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="series" [items]="series"
[pagination]="pagination" [pagination]="pagination"
@ -54,22 +71,32 @@
(applyFilter)="updateFilter($event)"> (applyFilter)="updateFilter($event)">
<ng-template #cardItem let-item let-position="idx"> <ng-template #cardItem let-item let-position="idx">
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()" <app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"
></app-series-card> ></app-series-card>
</ng-template> </ng-template>
<div *ngIf="!filterActive && series.length === 0"> @if(!filterActive && series.length === 0) {
<div>
<ng-template #noData> <ng-template #noData>
{{t('no-data')}} {{t('no-data')}}
</ng-template> </ng-template>
</div> </div>
}
<div *ngIf="filterActive && series.length === 0"> @if(filterActive && series.length === 0) {
<div>
<ng-template #noData> <ng-template #noData>
{{t('no-data-filtered')}} {{t('no-data-filtered')}}
</ng-template> </ng-template>
</div> </div>
}
</app-card-detail-layout> </app-card-detail-layout>
}
</div> </div>
}
</ng-container> </ng-container>
</div> </div>

View File

@ -1,4 +1,4 @@
import {AsyncPipe, DatePipe, DOCUMENT, NgIf, NgStyle} from '@angular/common'; import {AsyncPipe, DatePipe, DOCUMENT, NgStyle} from '@angular/common';
import { import {
AfterContentChecked, AfterContentChecked,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -15,7 +15,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import {Title} from '@angular/platform-browser'; import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {NgbModal, NgbOffcanvas, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {debounceTime, take} from 'rxjs/operators'; import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
@ -60,6 +60,10 @@ import {TranslocoDatePipe} from "@jsverse/transloco-locale";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe"; import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe"; import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
import {
SmartCollectionDrawerComponent
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -67,7 +71,10 @@ import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
styleUrls: ['./collection-detail.component.scss'], styleUrls: ['./collection-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip, SafeHtmlPipe, TranslocoDatePipe, DatePipe, DefaultDatePipe, ProviderImagePipe, ProviderNamePipe, AsyncPipe] imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgStyle, ImageComponent, ReadMoreComponent,
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
SafeHtmlPipe, TranslocoDatePipe, DatePipe, DefaultDatePipe, ProviderImagePipe, ProviderNamePipe, AsyncPipe,
PromotedIconComponent]
}) })
export class CollectionDetailComponent implements OnInit, AfterContentChecked { export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -83,6 +90,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
private readonly actionFactoryService = inject(ActionFactoryService); private readonly actionFactoryService = inject(ActionFactoryService);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal); private readonly modalService = inject(NgbModal);
private readonly offcanvasService = inject(NgbOffcanvas);
private readonly titleService = inject(Title); private readonly titleService = inject(Title);
private readonly jumpbarService = inject(JumpbarService); private readonly jumpbarService = inject(JumpbarService);
private readonly actionService = inject(ActionService); private readonly actionService = inject(ActionService);
@ -92,6 +100,9 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly Breakpoint = Breakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -327,6 +338,12 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}); });
} }
protected readonly ScrobbleProvider = ScrobbleProvider; openSyncDetailDrawer() {
protected readonly Breakpoint = Breakpoint;
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.collection = this.collectionTag;
ref.componentInstance.series = this.series;
}
} }

View File

@ -163,7 +163,7 @@ export class DashboardComponent implements OnInit {
.pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); .pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
break; break;
case StreamType.MoreInGenre: case StreamType.MoreInGenre:
s.api = this.metadataService.getAllGenres().pipe( s.api = this.metadataService.getAllGenres([], QueryContext.Dashboard).pipe(
map(genres => { map(genres => {
this.genre = genres[Math.floor(Math.random() * genres.length)]; this.genre = genres[Math.floor(Math.random() * genres.length)];
return this.genre; return this.genre;

View File

@ -699,7 +699,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
loadSeries(seriesId: number, loadExternal: boolean = false) { loadSeries(seriesId: number, loadExternal: boolean = false) {
this.seriesService.getMetadata(seriesId).subscribe(metadata => { this.seriesService.getMetadata(seriesId).subscribe(metadata => {
this.seriesMetadata = metadata; this.seriesMetadata = {...metadata};
this.cdRef.markForCheck(); this.cdRef.markForCheck();
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return; if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;

View File

@ -4,8 +4,8 @@ import {
Component, Component,
ContentChild, EventEmitter, ContentChild, EventEmitter,
inject, inject,
Input, Input, OnChanges,
OnInit, Output, OnInit, Output, SimpleChanges,
TemplateRef TemplateRef
} from '@angular/core'; } from '@angular/core';
import {NgTemplateOutlet} from "@angular/common"; import {NgTemplateOutlet} from "@angular/common";
@ -20,7 +20,7 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
styleUrls: ['./badge-expander.component.scss'], styleUrls: ['./badge-expander.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class BadgeExpanderComponent implements OnInit { export class BadgeExpanderComponent implements OnInit, OnChanges {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
@ -47,6 +47,11 @@ export class BadgeExpanderComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
ngOnChanges(changes: SimpleChanges) {
this.visibleItems = this.items.slice(0, this.itemsTillExpander);
this.cdRef.markForCheck();
}
toggleVisible() { toggleVisible() {
this.toggle.emit(); this.toggle.emit();
if (!this.allowToggle) return; if (!this.allowToggle) return;

View File

@ -1,5 +1,7 @@
<ng-container *transloco="let t; read: 'customize-sidenav-streams'"> <ng-container *transloco="let t; read: 'customize-sidenav-streams'">
<form [formGroup]="listForm"> <form [formGroup]="listForm">
<app-bulk-operations [modalMode]="false" [actionCallback]="bulkActionCallback"></app-bulk-operations>
@if (items.length > 3) { @if (items.length > 3) {
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<label for="sidenav-stream-filter" class="form-label">{{t('filter')}}</label> <label for="sidenav-stream-filter" class="form-label">{{t('filter')}}</label>
@ -26,7 +28,7 @@
</form> </form>
</div> </div>
<app-bulk-operations [modalMode]="true" [topOffset]="0" [actionCallback]="bulkActionCallback"></app-bulk-operations>
<div style="max-height: 500px; overflow-y: auto"> <div style="max-height: 500px; overflow-y: auto">
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" <app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)"
[accessibilityMode]="pageOperationsForm.get('accessibilityMode')!.value" [accessibilityMode]="pageOperationsForm.get('accessibilityMode')!.value"

View File

@ -569,7 +569,7 @@ export class VolumeDetailComponent implements OnInit {
} }
} }
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) { async handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) { switch (action.action) {
case(Action.MarkAsRead): case(Action.MarkAsRead):
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, _ => this.setContinuePoint()); this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, _ => this.setContinuePoint());
@ -586,6 +586,12 @@ export class VolumeDetailComponent implements OnInit {
case(Action.IncognitoRead): case(Action.IncognitoRead):
this.readerService.readChapter(this.libraryId, this.seriesId, chapter, true); this.readerService.readChapter(this.libraryId, this.seriesId, chapter, true);
break; break;
case(Action.Delete):
await this.actionService.deleteChapter(chapter.id, (res) => {
if (!res) return;
this.navigateToSeries();
});
break;
case (Action.SendTo): case (Action.SendTo):
const device = (action._extra!.data as Device); const device = (action._extra!.data as Device);
this.actionService.sendToDevice([chapter.id], device); this.actionService.sendToDevice([chapter.id], device);

View File

@ -1465,6 +1465,7 @@
"select-all": "{{common.select-all}}", "select-all": "{{common.select-all}}",
"filter-label": "{{common.filter}}", "filter-label": "{{common.filter}}",
"last-sync-title": "Last Sync:", "last-sync-title": "Last Sync:",
"last-sync-tooltip": "Kavita syncs daily against upstream collection provider.",
"source-url-title": "Source Url:", "source-url-title": "Source Url:",
"total-series-title": "Total Series:", "total-series-title": "Total Series:",
"missing-series-title": "Missing Series:" "missing-series-title": "Missing Series:"