mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-04 22:25:36 -04:00
Smart Collection UI Changes (#3332)
This commit is contained in:
parent
dcd281c5c3
commit
9e299d08b9
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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)
|
||||||
|
@ -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})
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -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"
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
@ -0,0 +1,5 @@
|
|||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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: ''});
|
||||||
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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"
|
||||||
|
@ -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);
|
||||||
|
@ -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:"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user