Smart Collection UI Changes (#3332)

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

View File

@ -58,16 +58,30 @@ public class ChapterController : BaseApiController
if (chapter == null)
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>

View File

@ -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>

View File

@ -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)

View File

@ -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})
{

View File

@ -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);

View File

@ -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);

View File

@ -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"

View File

@ -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;

View File

@ -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,

View File

@ -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);
}

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -8,7 +8,11 @@
@if (settingsForm.get('taskScan'); as formControl) {
<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>

View File

@ -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);
});
}

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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 {

View File

@ -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: ''});

View File

@ -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>

View File

@ -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;
}
}

View File

@ -163,7 +163,7 @@ export class DashboardComponent implements OnInit {
.pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
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;

View File

@ -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;

View File

@ -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;

View File

@ -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"

View File

@ -569,7 +569,7 @@ export class VolumeDetailComponent implements OnInit {
}
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
async handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
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);

View File

@ -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:"