mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -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)
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -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
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("genres")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
|
||||
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])]
|
||||
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();
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -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)
|
||||
|
@ -21,7 +21,7 @@ public interface IGenreRepository
|
||||
Task<IList<Genre>> GetAllGenresAsync();
|
||||
Task<IList<Genre>> GetAllGenresByNamesAsync(IEnumerable<string> normalizedNames);
|
||||
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<GenreTagDto> GetRandomGenre();
|
||||
Task<GenreTagDto> GetGenreById(int id);
|
||||
@ -115,10 +115,10 @@ public class GenreRepository : IGenreRepository
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="libraryIds"></param>
|
||||
/// <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 userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
var userLibs = await _context.Library.GetUserLibraries(userId, context).ToListAsync();
|
||||
|
||||
if (libraryIds is {Count: > 0})
|
||||
{
|
||||
|
@ -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<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);
|
||||
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
|
||||
|
||||
|
@ -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<string>(["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);
|
||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -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"
|
||||
|
@ -16,6 +16,9 @@ export interface UserCollection {
|
||||
source: ScrobbleProvider;
|
||||
sourceUrl: string | null;
|
||||
totalSourceCount: number;
|
||||
/**
|
||||
* HTML anchors separated by <br/>
|
||||
*/
|
||||
missingSeriesFromSource: string | null;
|
||||
ageRating: AgeRating;
|
||||
itemCount: number;
|
||||
|
@ -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,
|
||||
|
@ -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<Array<Tag>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getAllGenres(libraries?: Array<number>) {
|
||||
getAllGenres(libraries?: Array<number>, 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<Array<Genre>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
|
@ -123,7 +123,7 @@
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('release-date-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'releaseDateLocked' }"></ng-container>
|
||||
<input
|
||||
class="form-control"
|
||||
|
@ -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) {
|
||||
<app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')">
|
||||
<ng-template #view>
|
||||
{{t(formControl.value)}}
|
||||
@if (formControl.value === customOption) {
|
||||
{{t(formControl.value)}} ({{settingsForm.get('taskScanCustom')?.value}})
|
||||
} @else {
|
||||
{{t(formControl.value)}}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
|
||||
@ -27,7 +31,7 @@
|
||||
aria-describedby="task-scan-validations">
|
||||
|
||||
@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) {
|
||||
<div>{{t('required')}}</div>
|
||||
}
|
||||
@ -47,7 +51,11 @@
|
||||
@if (settingsForm.get('taskBackup'); as formControl) {
|
||||
<app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')">
|
||||
<ng-template #view>
|
||||
{{t(formControl.value)}}
|
||||
@if (formControl.value === customOption) {
|
||||
{{t(formControl.value)}} ({{settingsForm.get('taskBackupCustom')?.value}})
|
||||
} @else {
|
||||
{{t(formControl.value)}}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
|
||||
@ -66,7 +74,7 @@
|
||||
aria-describedby="task-scan-validations">
|
||||
|
||||
@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) {
|
||||
<div>{{t('required')}}</div>
|
||||
}
|
||||
@ -87,7 +95,11 @@
|
||||
@if (settingsForm.get('taskCleanup'); as formControl) {
|
||||
<app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')">
|
||||
<ng-template #view>
|
||||
{{t(formControl.value)}}
|
||||
@if (formControl.value === customOption) {
|
||||
{{t(formControl.value)}} ({{settingsForm.get('taskCleanupCustom')?.value}})
|
||||
} @else {
|
||||
{{t(formControl.value)}}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
|
||||
@ -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) {
|
||||
<div id="task-cleanup-validations" class="invalid-feedback">
|
||||
@if (settingsForm.get('taskCleanupCustom')?.invalid) {
|
||||
<div id="task-cleanup-validations" class="invalid-feedback" style="display: inline-block">
|
||||
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
|
||||
<div>{{t('required')}}</div>
|
||||
}
|
||||
@ -123,10 +135,6 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<h4>{{t('adhoc-tasks-title')}}</h4>
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@
|
||||
<label for="library-name" class="form-label">{{t('name-label')}}</label>
|
||||
<input id="library-name" class="form-control" formControlName="title" type="text"
|
||||
[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">
|
||||
@if (collectionTagForm.get('title')?.errors?.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
.bulk-select-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
.bulk-select {
|
||||
background-color: var(--bulk-selection-bg-color);
|
||||
|
@ -73,9 +73,11 @@
|
||||
</div>
|
||||
@if (title.length > 0 || actions.length > 0) {
|
||||
<div class="card-title-container">
|
||||
<span class="card-format">
|
||||
<span>
|
||||
@if (showFormat) {
|
||||
<app-series-format [format]="format"></app-series-format>
|
||||
<span class="card-format">
|
||||
<app-series-format [format]="format"></app-series-format>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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<HTMLAnchorElement>;
|
||||
|
||||
|
||||
private readonly offcanvasService = inject(NgbOffcanvas);
|
||||
|
||||
handleClick() {
|
||||
if (this.previewOnClick) {
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''});
|
||||
|
@ -1,75 +1,102 @@
|
||||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read: 'collection-detail'">
|
||||
<div #companionBar>
|
||||
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<ng-container title>
|
||||
<h4 *ngIf="collectionTag !== undefined">
|
||||
{{collectionTag.title}}<span class="ms-1" *ngIf="collectionTag.promoted">(<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>
|
||||
</h4>
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
||||
</ng-container>
|
||||
</app-side-nav-companion-bar>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid" *ngIf="collectionTag !== undefined">
|
||||
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
|
||||
<div class="row mb-3">
|
||||
<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>
|
||||
@if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null
|
||||
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
|
||||
<div class="under-image">
|
||||
<app-image [imageUrl]="collectionTag.source | providerImage"
|
||||
width="16px" height="16px"
|
||||
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
|
||||
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
|
||||
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i>
|
||||
</div>
|
||||
@if (series) {
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<ng-container title>
|
||||
@if (collectionTag) {
|
||||
<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>
|
||||
</h4>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
@if (summary.length > 0) {
|
||||
<div class="mb-2">
|
||||
<app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
||||
</ng-container>
|
||||
</app-side-nav-companion-bar>
|
||||
}
|
||||
|
||||
|
||||
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
<app-card-detail-layout *ngIf="filter"
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<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"
|
||||
></app-series-card>
|
||||
</ng-template>
|
||||
|
||||
<div *ngIf="!filterActive && series.length === 0">
|
||||
<ng-template #noData>
|
||||
{{t('no-data')}}
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<div *ngIf="filterActive && series.length === 0">
|
||||
<ng-template #noData>
|
||||
{{t('no-data-filtered')}}
|
||||
</ng-template>
|
||||
</div>
|
||||
</app-card-detail-layout>
|
||||
</div>
|
||||
|
||||
@if (collectionTag) {
|
||||
<div class="container-fluid">
|
||||
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
|
||||
<div class="row mb-3">
|
||||
<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>
|
||||
|
||||
@if (collectionTag.source !== ScrobbleProvider.Kavita && collectionTag.missingSeriesFromSource !== null
|
||||
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
|
||||
<div class="under-image">
|
||||
<app-image [imageUrl]="collectionTag.source | providerImage"
|
||||
width="16px" height="16px"
|
||||
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
|
||||
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
|
||||
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
@if (summary.length > 0) {
|
||||
<div class="mb-2">
|
||||
<app-read-more [text]="summary" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
|
||||
</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>
|
||||
<hr>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
@if (filter) {
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<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"
|
||||
></app-series-card>
|
||||
</ng-template>
|
||||
|
||||
@if(!filterActive && series.length === 0) {
|
||||
<div>
|
||||
<ng-template #noData>
|
||||
{{t('no-data')}}
|
||||
</ng-template>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if(filterActive && series.length === 0) {
|
||||
<div>
|
||||
<ng-template #noData>
|
||||
{{t('no-data-filtered')}}
|
||||
</ng-template>
|
||||
</div>
|
||||
}
|
||||
|
||||
</app-card-detail-layout>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
</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 {
|
||||
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<HTMLDivElement> | undefined;
|
||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -1,5 +1,7 @@
|
||||
<ng-container *transloco="let t; read: 'customize-sidenav-streams'">
|
||||
<form [formGroup]="listForm">
|
||||
<app-bulk-operations [modalMode]="false" [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
@if (items.length > 3) {
|
||||
<div class="row g-0 mb-2">
|
||||
<label for="sidenav-stream-filter" class="form-label">{{t('filter')}}</label>
|
||||
@ -26,7 +28,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<app-bulk-operations [modalMode]="true" [topOffset]="0" [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
<div style="max-height: 500px; overflow-y: auto">
|
||||
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)"
|
||||
[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) {
|
||||
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);
|
||||
|
@ -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:"
|
||||
|
Loading…
x
Reference in New Issue
Block a user