From 9e299d08b95680bd04aa4a678d115a9370612e98 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 4 Nov 2024 11:16:17 -0600 Subject: [PATCH] Smart Collection UI Changes (#3332) --- API/Controllers/ChapterController.cs | 26 ++- API/Controllers/MetadataController.cs | 13 +- API/Controllers/SettingsController.cs | 16 +- API/Data/Repositories/GenreRepository.cs | 6 +- API/Data/Repositories/SeriesRepository.cs | 3 +- API/Services/TaskScheduler.cs | 45 +++-- UI/Web/package-lock.json | 15 +- UI/Web/src/app/_models/collection-tag.ts | 3 + .../src/app/_services/colorscape.service.ts | 55 +++--- UI/Web/src/app/_services/metadata.service.ts | 10 +- .../edit-chapter-modal.component.html | 2 +- .../smart-collection-drawer.component.html | 40 +++++ .../smart-collection-drawer.component.scss | 5 + .../smart-collection-drawer.component.ts | 58 +++++++ .../manage-tasks-settings.component.html | 30 ++-- .../manage-tasks-settings.component.ts | 60 +++++-- .../edit-collection-tags.component.html | 2 +- .../bulk-operations.component.scss | 1 + .../cards/card-item/card-item.component.html | 6 +- .../entity-title/entity-title.component.ts | 4 + .../external-series-card.component.ts | 4 +- .../collection-detail.component.html | 161 ++++++++++-------- .../collection-detail.component.ts | 27 ++- .../_components/dashboard.component.ts | 2 +- .../series-detail/series-detail.component.ts | 2 +- .../badge-expander.component.ts | 11 +- .../customize-sidenav-streams.component.html | 4 +- .../volume-detail/volume-detail.component.ts | 8 +- UI/Web/src/assets/langs/en.json | 1 + 29 files changed, 431 insertions(+), 189 deletions(-) create mode 100644 UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.html create mode 100644 UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss create mode 100644 UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index b2c65282c..3b1746621 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -58,16 +58,30 @@ public class ChapterController : BaseApiController if (chapter == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var vol = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; - _unitOfWork.ChapterRepository.Remove(chapter); + var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters); + 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); - return Ok(true); + _unitOfWork.VolumeRepository.Remove(vol); + } + 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); } /// diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 188bf839c..51c8c4a01 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; @@ -33,18 +34,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// String separated libraryIds or null for all genres /// [HttpGet("genres")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] - public async Task>> GetAllGenres(string? libraryIds) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])] + public async Task>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); - // NOTE: libraryIds isn't hooked up in the frontend - if (ids is {Count: > 0}) - { - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids)); - } - - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId())); + return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index d884d05c8..65e9587e1 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -163,6 +163,7 @@ public class SettingsController : BaseApiController bookmarkDirectory = _directoryService.BookmarkDirectory; } + var updateTask = false; foreach (var setting in currentSettings) { if (setting.Key == ServerSettingKey.OnDeckProgressDays && @@ -204,7 +205,7 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } - UpdateSchedulingSettings(setting, updateSettingsDto); + updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); UpdateEmailSettings(setting, updateSettingsDto); @@ -348,6 +349,11 @@ public class SettingsController : BaseApiController UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); } + if (updateTask) + { + BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); + } + if (updateSettingsDto.EnableFolderWatching) { BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); @@ -379,25 +385,31 @@ public class SettingsController : BaseApiController _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 (updateSettingsDto.TotalBackup) setting.Value = updateSettingsDto.TaskBackup; _unitOfWork.SettingsRepository.Update(setting); + + return true; } if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) { setting.Value = updateSettingsDto.TaskScan; _unitOfWork.SettingsRepository.Update(setting); + return true; } if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) { setting.Value = updateSettingsDto.TaskCleanup; _unitOfWork.SettingsRepository.Update(setting); + return true; } + return false; } private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index f6916e21e..7492ba5dd 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -21,7 +21,7 @@ public interface IGenreRepository Task> GetAllGenresAsync(); Task> GetAllGenresByNamesAsync(IEnumerable normalizedNames); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); - Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null); + Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None); Task GetCountAsync(); Task GetRandomGenre(); Task GetGenreById(int id); @@ -115,10 +115,10 @@ public class GenreRepository : IGenreRepository /// /// /// - public async Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null) + public async Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None) { 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}) { diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 835490e0a..7cfcd36e6 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -65,6 +65,7 @@ public enum QueryContext { None = 1, Search = 2, + [Obsolete("Use Dashboard")] Recommended = 3, Dashboard = 4, } @@ -1509,7 +1510,7 @@ public class SeriesRepository : ISeriesRepository public async Task> 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); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 24083d19d..04d6df2c1 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -12,6 +12,7 @@ using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.SignalR; using Hangfire; +using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services; @@ -121,23 +122,32 @@ public class TaskScheduler : ITaskScheduler public async Task ScheduleTasks() { _logger.LogInformation("Scheduling reoccurring tasks"); + var nonCronOptions = new List(["disabled", "daily", "weekly"]); 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; _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); } - else - { - RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), - Cron.Daily, RecurringJobOptions); - } + 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); var schedule = CronConverter.ConvertToCronNotation(setting); @@ -149,16 +159,21 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => schedule, RecurringJobOptions); } - else - { - RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), - Cron.Weekly, RecurringJobOptions); - } setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value; - _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); - RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), - CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); + if (setting == null || (!nonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting))) + { + _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), + 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(), Cron.Daily, RecurringJobOptions); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 6ebea3431..64d798000 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -464,7 +464,6 @@ "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", - "dev": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -492,7 +491,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", - "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -507,7 +505,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", - "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4010,8 +4007,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4518,7 +4514,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4528,7 +4523,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7470,8 +7464,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7742,7 +7735,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.77.6", @@ -7776,7 +7769,6 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -8331,7 +8323,6 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/app/_models/collection-tag.ts b/UI/Web/src/app/_models/collection-tag.ts index e9f072f51..2679b5f73 100644 --- a/UI/Web/src/app/_models/collection-tag.ts +++ b/UI/Web/src/app/_models/collection-tag.ts @@ -16,6 +16,9 @@ export interface UserCollection { source: ScrobbleProvider; sourceUrl: string | null; totalSourceCount: number; + /** + * HTML anchors separated by
+ */ missingSeriesFromSource: string | null; ageRating: AgeRating; itemCount: number; diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 95deace1e..88cbd7460 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -2,7 +2,6 @@ import { Injectable, Inject } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; import {NavigationEnd, Router} from "@angular/router"; -import {debounceTime} from "rxjs/operators"; interface ColorSpace { primary: string; @@ -18,18 +17,9 @@ interface ColorSpaceRGBA { complementary: RGBAColor; } -interface RGBAColor { - r: number; - g: number; - b: number; - a: number; -} - -interface RGB { - r: number; - g: number; - b: number; -} +interface RGBAColor {r: number;g: number;b: number;a: number;} +interface RGB { r: number;g: number; b: number; } +interface HSL { h: number; s: number; l: number; } const colorScapeSelector = 'colorscape'; @@ -49,7 +39,6 @@ export class ColorscapeService { private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { - this.router.events.pipe( filter(event => event instanceof NavigationEnd), tap(() => this.checkAndResetColorscapeAfterDelay()) @@ -100,9 +89,17 @@ export class ColorscapeService { this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); return; } - 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 ? this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : this.defaultColors(); @@ -144,7 +141,8 @@ export class ColorscapeService { }; } - private parseColorToRGBA(color: string): RGBAColor { + private parseColorToRGBA(color: string) { + if (color.startsWith('#')) { return this.hexToRGBA(color); } else if (color.startsWith('rgb')) { @@ -246,12 +244,19 @@ export class ColorscapeService { 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 { + + const easedProgress = this.easeInOutCubic(progress); + return { - r: Math.round(color1.r + (color2.r - color1.r) * progress), - g: Math.round(color1.g + (color2.g - color1.g) * progress), - b: Math.round(color1.b + (color2.b - color1.b) * progress), - a: color1.a + (color2.a - color1.a) * progress + r: Math.round(color1.r + (color2.r - color1.r) * easedProgress), + g: Math.round(color1.g + (color2.g - color1.g) * easedProgress), + b: Math.round(color1.b + (color2.b - color1.b) * easedProgress), + 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}; lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0); 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; s: 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)}`; } - private rgbToHsl(rgb: RGB): { h: number; s: number; l: number } { + private rgbToHsl(rgb: RGB): HSL { const r = rgb.r / 255; const g = rgb.g / 255; const b = rgb.b / 255; @@ -430,7 +435,7 @@ export class ColorscapeService { return { h, s, l }; } - private hslToRgb(hsl: { h: number; s: number; l: number }): RGB { + private hslToRgb(hsl: HSL): RGB { const { h, s, l } = hsl; 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 { h: (hsl.h + amount / 360) % 1, s: hsl.s, diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index a87ad6844..314e5c37b 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; import {of} from 'rxjs'; @@ -19,6 +19,7 @@ import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; import {IHasCast} from "../_models/common/i-has-cast"; import {TextResonse} from "../_types/text-response"; +import {QueryContext} from "../_models/metadata/v2/query-context"; @Injectable({ providedIn: 'root' @@ -62,11 +63,14 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } - getAllGenres(libraries?: Array) { + getAllGenres(libraries?: Array, context: QueryContext = QueryContext.None) { let method = 'metadata/genres' if (libraries != undefined && libraries.length > 0) { - method += '?libraryIds=' + libraries.join(','); + method += '?libraryIds=' + libraries.join(',') + '&context=' + context; + } else { + method += '?context=' + context; } + return this.httpClient.get>(this.baseUrl + method); } diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html index 62f9e4197..b9fa21953 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -123,7 +123,7 @@
-
+
+
+
+ {{collection.title}} +
+ +
+ +
+ +
+ + + {{collection.lastSyncUtc | utcToLocalTime | date:'shortDate' | defaultDate}} + + +
+ + +
+ + + {{collection.totalSourceCount - series.length}} / {{collection.totalSourceCount | number}} + + + + @if(collection.missingSeriesFromSource) { +

+ } + @for(s of series; track s.name) { + + } +
+
+
+
+ diff --git a/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss new file mode 100644 index 000000000..55f5cfd90 --- /dev/null +++ b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss @@ -0,0 +1,5 @@ +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts new file mode 100644 index 000000000..5cffa8944 --- /dev/null +++ b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts @@ -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(); + } +} diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index 064c7349b..28c2dd867 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -8,7 +8,11 @@ @if (settingsForm.get('taskScan'); as formControl) { - {{t(formControl.value)}} + @if (formControl.value === customOption) { + {{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}}) + } @else { + {{t(formControl.value)}} + } @@ -27,7 +31,7 @@ aria-describedby="task-scan-validations"> @if (settingsForm.dirty || !settingsForm.untouched) { -
+
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
{{t('required')}}
} @@ -47,7 +51,11 @@ @if (settingsForm.get('taskBackup'); as formControl) { - {{t(formControl.value)}} + @if (formControl.value === customOption) { + {{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}}) + } @else { + {{t(formControl.value)}} + } @@ -66,7 +74,7 @@ aria-describedby="task-scan-validations"> @if (settingsForm.dirty || !settingsForm.untouched) { -
+
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
{{t('required')}}
} @@ -87,7 +95,11 @@ @if (settingsForm.get('taskCleanup'); as formControl) { - {{t(formControl.value)}} + @if (formControl.value === customOption) { + {{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}}) + } @else { + {{t(formControl.value)}} + } @@ -105,8 +117,8 @@ [class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched" aria-describedby="task-scan-validations"> - @if (settingsForm.dirty || !settingsForm.untouched) { -
+ @if (settingsForm.get('taskCleanupCustom')?.invalid) { +
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
{{t('required')}}
} @@ -123,10 +135,6 @@
-
- -
-

{{t('adhoc-tasks-title')}}

diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index 81d5b2af9..5d4dc9ad3 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -165,19 +165,25 @@ export class ManageTasksSettingsComponent implements OnInit { this.validateCronExpression('taskBackupCustom'); this.validateCronExpression('taskCleanupCustom'); + // Setup individual pipelines to save the changes automatically + + // Automatically save settings as we edit them this.settingsForm.valueChanges.pipe( distinctUntilChanged(), - debounceTime(100), - filter(_ => this.settingsForm.valid), + debounceTime(500), + filter(_ => this.isFormValid()), 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(_ => { const data = this.packData(); return this.settingsService.updateServerSettings(data); }), tap(settings => { this.serverSettings = settings; - this.resetForm(); + this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay()); this.cdRef.markForCheck(); }) @@ -202,6 +208,8 @@ export class ManageTasksSettingsComponent implements OnInit { } } + + // Validate the custom fields for cron expressions validateCronExpression(controlName: string) { this.settingsForm.get(controlName)?.valueChanges.pipe( @@ -213,12 +221,43 @@ export class ManageTasksSettingsComponent implements OnInit { } else { this.settingsForm.get(controlName)?.setErrors({ invalidCron: true }); } + + this.settingsForm.updateValueAndValidity(); // Ensure form validity reflects changes this.cdRef.markForCheck(); }), takeUntilDestroyed(this.destroyRef) ).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() { 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; } + console.log('modelSettings: ', 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) { task.api.subscribe((data: any) => { if (task.successMessage.length > 0) { @@ -290,8 +318,6 @@ export class ManageTasksSettingsComponent implements OnInit { if (task.successFunction) { task.successFunction(data); } - }, (err: any) => { - console.error('error: ', err); }); } diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index d7453a227..fd90cc8a0 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -16,7 +16,7 @@ - @if (collectionTagForm.dirty || collectionTagForm.touched) { + @if (collectionTagForm.dirty || !collectionTagForm.untouched) {
@if (collectionTagForm.get('title')?.errors?.required) {
{{t('required-field')}}
diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss index 4bbd1f73e..4427c8dba 100644 --- a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss @@ -1,5 +1,6 @@ .bulk-select-container { position: absolute; + width: 100%; .bulk-select { background-color: var(--bulk-selection-bg-color); diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 0e47ea16b..9a9b134af 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -73,9 +73,11 @@
@if (title.length > 0 || actions.length > 0) {
- + @if (showFormat) { - + + + } diff --git a/UI/Web/src/app/cards/entity-title/entity-title.component.ts b/UI/Web/src/app/cards/entity-title/entity-title.component.ts index 665fb55c5..4b6144803 100644 --- a/UI/Web/src/app/cards/entity-title/entity-title.component.ts +++ b/UI/Web/src/app/cards/entity-title/entity-title.component.ts @@ -108,6 +108,8 @@ export class EntityTitleComponent implements OnInit { let renderText = ''; if (this.titleName !== '' && this.prioritizeTitleName) { 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) { renderText = ''; } else { @@ -120,6 +122,8 @@ export class EntityTitleComponent implements OnInit { let renderText = ''; if (this.titleName !== '' && this.prioritizeTitleName) { 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) { renderText = ''; } else { diff --git a/UI/Web/src/app/cards/external-series-card/external-series-card.component.ts b/UI/Web/src/app/cards/external-series-card/external-series-card.component.ts index 0cd633fbb..03bff4905 100644 --- a/UI/Web/src/app/cards/external-series-card/external-series-card.component.ts +++ b/UI/Web/src/app/cards/external-series-card/external-series-card.component.ts @@ -25,6 +25,8 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; changeDetection: ChangeDetectionStrategy.OnPush }) export class ExternalSeriesCardComponent { + private readonly offcanvasService = inject(NgbOffcanvas); + @Input({required: true}) data!: ExternalSeries; /** * 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; - private readonly offcanvasService = inject(NgbOffcanvas); - handleClick() { if (this.previewOnClick) { const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''}); diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index b316f7d5f..bfe7aaba7 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -1,75 +1,102 @@
- - -

- {{collectionTag.title}}() - -

-
{{t('item-count', {num: series.length})}}
-
-
-
- -
- @if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) { -
-
- - @if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null - && series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) { -
- - {{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}} - -
+ @if (series) { + + + @if (collectionTag) { +

+ {{collectionTag.title}} + @if(collectionTag.promoted) { + () + } + +

} -
-
- @if (summary.length > 0) { -
- -
- } -
-
-
+
{{t('item-count', {num: series.length})}}
+ + } - - - - - - - - - -
- - {{t('no-data')}} - -
- -
- - {{t('no-data-filtered')}} - -
-
+ + @if (collectionTag) { +
+ @if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) { +
+
+ + + @if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null + && series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) { +
+ + {{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}} + +
+ } +
+
+ @if (summary.length > 0) { +
+ +
+ + @if (collectionTag.source !== ScrobbleProvider.Kavita) { +
+ +
+ } + } +
+
+
+ } + + + + + + @if (filter) { + + + + + + @if(!filterActive && series.length === 0) { +
+ + {{t('no-data')}} + +
+ } + + @if(filterActive && series.length === 0) { +
+ + {{t('no-data-filtered')}} + +
+ } + +
+ } + +
+ } +
-
\ No newline at end of file +
diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index c481b4734..9e8d4fc0e 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -1,4 +1,4 @@ -import {AsyncPipe, DatePipe, DOCUMENT, NgIf, NgStyle} from '@angular/common'; +import {AsyncPipe, DatePipe, DOCUMENT, NgStyle} from '@angular/common'; import { AfterContentChecked, ChangeDetectionStrategy, @@ -15,7 +15,7 @@ import { } from '@angular/core'; import {Title} from '@angular/platform-browser'; 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 {debounceTime, take} from 'rxjs/operators'; 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 {ProviderImagePipe} from "../../../_pipes/provider-image.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({ selector: 'app-collection-detail', @@ -67,7 +71,10 @@ import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe"; styleUrls: ['./collection-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, 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 { @@ -83,6 +90,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { private readonly actionFactoryService = inject(ActionFactoryService); private readonly accountService = inject(AccountService); private readonly modalService = inject(NgbModal); + private readonly offcanvasService = inject(NgbOffcanvas); private readonly titleService = inject(Title); private readonly jumpbarService = inject(JumpbarService); private readonly actionService = inject(ActionService); @@ -92,6 +100,9 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { private readonly cdRef = inject(ChangeDetectorRef); private readonly scrollService = inject(ScrollService); + protected readonly ScrobbleProvider = ScrobbleProvider; + protected readonly Breakpoint = Breakpoint; + @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; @@ -327,6 +338,12 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { }); } - protected readonly ScrobbleProvider = ScrobbleProvider; - protected readonly Breakpoint = Breakpoint; + openSyncDetailDrawer() { + + const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''}); + ref.componentInstance.collection = this.collectionTag; + ref.componentInstance.series = this.series; + } + + } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index 19f0635e3..efb3b2290 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -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})); break; case StreamType.MoreInGenre: - s.api = this.metadataService.getAllGenres().pipe( + s.api = this.metadataService.getAllGenres([], QueryContext.Dashboard).pipe( map(genres => { this.genre = genres[Math.floor(Math.random() * genres.length)]; return this.genre; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index d8ea1b8f6..8893d78aa 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -699,7 +699,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { loadSeries(seriesId: number, loadExternal: boolean = false) { this.seriesService.getMetadata(seriesId).subscribe(metadata => { - this.seriesMetadata = metadata; + this.seriesMetadata = {...metadata}; this.cdRef.markForCheck(); if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return; diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts index 2ba6be010..1e7365629 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.ts @@ -4,8 +4,8 @@ import { Component, ContentChild, EventEmitter, inject, - Input, - OnInit, Output, + Input, OnChanges, + OnInit, Output, SimpleChanges, TemplateRef } from '@angular/core'; import {NgTemplateOutlet} from "@angular/common"; @@ -20,7 +20,7 @@ import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; styleUrls: ['./badge-expander.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class BadgeExpanderComponent implements OnInit { +export class BadgeExpanderComponent implements OnInit, OnChanges { private readonly cdRef = inject(ChangeDetectorRef); @@ -47,6 +47,11 @@ export class BadgeExpanderComponent implements OnInit { this.cdRef.markForCheck(); } + ngOnChanges(changes: SimpleChanges) { + this.visibleItems = this.items.slice(0, this.itemsTillExpander); + this.cdRef.markForCheck(); + } + toggleVisible() { this.toggle.emit(); if (!this.allowToggle) return; diff --git a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html index 7a497f212..11fce355a 100644 --- a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html +++ b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.html @@ -1,5 +1,7 @@
+ + @if (items.length > 3) {
@@ -26,7 +28,7 @@
- +
, chapter: Chapter) { + async handleChapterActionCallback(action: ActionItem, chapter: Chapter) { switch (action.action) { case(Action.MarkAsRead): this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, _ => this.setContinuePoint()); @@ -586,6 +586,12 @@ export class VolumeDetailComponent implements OnInit { case(Action.IncognitoRead): this.readerService.readChapter(this.libraryId, this.seriesId, chapter, true); break; + case(Action.Delete): + await this.actionService.deleteChapter(chapter.id, (res) => { + if (!res) return; + this.navigateToSeries(); + }); + break; case (Action.SendTo): const device = (action._extra!.data as Device); this.actionService.sendToDevice([chapter.id], device); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 6309d57d7..690e08351 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1465,6 +1465,7 @@ "select-all": "{{common.select-all}}", "filter-label": "{{common.filter}}", "last-sync-title": "Last Sync:", + "last-sync-tooltip": "Kavita syncs daily against upstream collection provider.", "source-url-title": "Source Url:", "total-series-title": "Total Series:", "missing-series-title": "Missing Series:"