This commit is contained in:
Joe Milazzo 2024-11-20 07:17:36 -06:00 committed by GitHub
parent cb810a2d8f
commit 3e3b6ba92b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1631 additions and 212 deletions

View File

@ -10,6 +10,7 @@ using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using AutoMapper;
using Hangfire;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@ -48,6 +49,9 @@ public abstract class AbstractDbTest
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_unitOfWork = new UnitOfWork(_context, mapper, null);
}

File diff suppressed because it is too large Load Diff

View File

@ -159,4 +159,6 @@ public class SeriesRepositoryTests
}
}
// TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck)
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -13,6 +14,7 @@ using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Nager.ArticleNumber;
namespace API.Controllers;
@ -22,12 +24,14 @@ public class ChapterController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub)
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
_logger = logger;
}
/// <summary>
@ -84,6 +88,83 @@ public class ChapterController : BaseApiController
return Ok(true);
}
/// <summary>
/// Deletes multiple chapters and any volumes with no leftover chapters
/// </summary>
/// <param name="seriesId">The ID of the series</param>
/// <param name="chapterIds">The IDs of the chapters to be deleted</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("delete-multiple")]
public async Task<ActionResult<bool>> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto)
{
try
{
var chapterIds = dto.ChapterIds;
if (chapterIds == null || chapterIds.Count == 0)
{
return BadRequest("ChapterIds required");
}
// Fetch all chapters to be deleted
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList();
// Group chapters by their volume
var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList();
var removedVolumes = new List<int>();
foreach (var volumeGroup in volumesToUpdate)
{
var volumeId = volumeGroup.Key;
var chaptersToDelete = volumeGroup.ToList();
// Fetch the volume
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist"));
// Check if all chapters in the volume are being deleted
var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count;
if (isVolumeToBeRemoved)
{
_unitOfWork.VolumeRepository.Remove(volume);
removedVolumes.Add(volume.Id);
}
else
{
// Remove only the specified chapters
_unitOfWork.ChapterRepository.Remove(chaptersToDelete);
}
}
if (!await _unitOfWork.CommitAsync()) return Ok(false);
// Send events for removed chapters
foreach (var chapter in chapters)
{
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved,
MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false);
}
// Send events for removed volumes
foreach (var volumeId in removedVolumes)
{
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved,
MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false);
}
return Ok(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occured while deleting chapters");
return BadRequest(_localizationService.Translate(User.GetUserId(), "generic-error"));
}
}
/// <summary>
/// Update chapter metadata
/// </summary>

View File

@ -110,18 +110,18 @@ public class DeviceController : BaseApiController
[HttpPost("send-to")]
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
{
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
var userId = User.GetUserId();
if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(userId, "greater-0", "ChapterIds"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
// // Validate that the device belongs to the user
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices);
if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed"));
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Devices);
if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(userId, "send-to-unallowed"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId);
@ -145,26 +145,30 @@ public class DeviceController : BaseApiController
}
/// <summary>
/// Attempts to send a whole series to a device.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("send-series-to")]
public async Task<ActionResult> SendSeriesToDevice(SendSeriesToDeviceDto dto)
{
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId"));
var userId = User.GetUserId();
if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "SeriesId"));
if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId"));
var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice();
if (!isEmailSetup)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email"));
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"started"), userId);
var series =
await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId,
SeriesIncludes.Volumes | SeriesIncludes.Chapters);
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList();
try
{
@ -173,16 +177,16 @@ public class DeviceController : BaseApiController
}
catch (KavitaException ex)
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
return BadRequest(await _localizationService.Translate(userId, ex.Message));
}
finally
{
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"),
"ended"), userId);
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to"));
return BadRequest(await _localizationService.Translate(userId, "generic-send-to"));
}
}

View File

@ -134,7 +134,7 @@ public class SeriesController : BaseApiController
var username = User.GetUsername();
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId}));
return Ok(await _seriesService.DeleteMultipleSeries([seriesId]));
}
[Authorize(Policy = "RequireAdminRole")]

View File

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs;
public class DeleteChaptersDto
{
public IList<int> ChapterIds { get; set; } = default!;
}

View File

@ -31,6 +31,7 @@ public interface IChapterRepository
{
void Update(Chapter chapter);
void Remove(Chapter chapter);
void Remove(IList<Chapter> chapters);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId);
@ -68,6 +69,11 @@ public class ChapterRepository : IChapterRepository
_context.Chapter.Remove(chapter);
}
public void Remove(IList<Chapter> chapters)
{
_context.Chapter.RemoveRange(chapters);
}
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None)
{
return await _context.Chapter

View File

@ -696,7 +696,7 @@ public class SeriesRepository : ISeriesRepository
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
//.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
@ -1065,9 +1065,10 @@ public class SeriesRepository : ISeriesRepository
query = await ApplyCollectionFilter(filter, query, userId, userRating);
query = BuildFilterQuery(userId, filter, query);
query = BuildFilterQuery(userId, filter, query);
query = query
.WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
.WhereIf(onlyParentSeries, s =>
@ -1078,7 +1079,8 @@ public class SeriesRepository : ISeriesRepository
return ApplyLimit(query
.Sort(userId, filter.SortOptions)
.AsSplitQuery(), filter.LimitTo);
.AsSplitQuery()
, filter.LimitTo);
}
private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating)

View File

@ -21,7 +21,7 @@ public class ExternalSeriesMetadata
public ICollection<ExternalRecommendation> ExternalRecommendations { get; set; } = null!;
/// <summary>
/// Average External Rating. -1 means not set
/// Average External Rating. -1 means not set, 0 - 100
/// </summary>
public int AverageExternalRating { get; set; } = 0;

View File

@ -14,6 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesFilter
{
private const float FloatingPointTolerance = 0.001f;
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages)
{
@ -255,7 +256,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
SeriesId = s.Id,
SeriesName = s.Name,
Percentage = s.Progress
.Where(p => p != null && p.AppUserId == userId)
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f
@ -298,7 +300,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id));
}
@ -312,7 +314,8 @@ public static class SeriesFilter
.Include(s => s.ExternalSeriesMetadata)
.Select(s => new
{
Series = s,
SeriesId = s.Id,
SeriesName = s.Name,
AverageRating = s.ExternalSeriesMetadata.AverageExternalRating
})
.AsSplitQuery()
@ -354,7 +357,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id));
}
@ -372,7 +375,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
SeriesId = s.Id,
SeriesName = s.Name,
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
.Select(p => (DateTime?) p.LastModified)
.DefaultIfEmpty()
@ -420,7 +424,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id));
}
@ -434,7 +438,8 @@ public static class SeriesFilter
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
SeriesId = s.Id,
SeriesName = s.Name,
MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId)
.Select(p => (DateTime?) p.LastModified)
.DefaultIfEmpty()
@ -480,7 +485,7 @@ public static class SeriesFilter
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
var ids = subQuery.Select(s => s.SeriesId);
return queryable.Where(s => ids.Contains(s.Id));
}

View File

@ -113,109 +113,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable;
}
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, Expression<Func<T, string>> propertySelector, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
return queryable.Where(lambda);
}
public static IQueryable<T> WhereGreaterThan<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector,
float value,
float tolerance = DefaultTolerance)
float value)
{
var parameter = selector.Parameters[0];
var propertyAccess = selector.Body;
// Absolute difference comparison: (propertyAccess - value) > tolerance
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance));
var combinedExpression = Expression.AndAlso(greaterThanExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
var lambda = Expression.Lambda<Func<T, bool>>(greaterThanExpression, parameter);
return source.Where(lambda);
}
public static IQueryable<T> WhereGreaterThanOrEqual<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector,
float value,
float tolerance = DefaultTolerance)
float value)
{
var parameter = selector.Parameters[0];
var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var greaterThanOrEqualExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.GreaterThanOrEqual(absoluteDifference, Expression.Constant(tolerance));
var combinedExpression = Expression.AndAlso(greaterThanOrEqualExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
var greaterThanExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value));
var lambda = Expression.Lambda<Func<T, bool>>(greaterThanExpression, parameter);
return source.Where(lambda);
}
public static IQueryable<T> WhereLessThan<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector,
float value,
float tolerance = DefaultTolerance)
float value)
{
var parameter = selector.Parameters[0];
var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance));
var combinedExpression = Expression.AndAlso(lessThanExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
var lambda = Expression.Lambda<Func<T, bool>>(lessThanExpression, parameter);
return source.Where(lambda);
}
public static IQueryable<T> WhereLessThanOrEqual<T>(this IQueryable<T> source,
Expression<Func<T, float>> selector,
float value,
float tolerance = DefaultTolerance)
float value)
{
var parameter = selector.Parameters[0];
var propertyAccess = selector.Body;
var difference = Expression.Subtract(propertyAccess, Expression.Constant(value));
var absoluteDifference = Expression.Condition(
Expression.LessThan(difference, Expression.Constant(0f)),
Expression.Negate(difference),
difference);
var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value));
var toleranceExpression = Expression.LessThanOrEqual(absoluteDifference, Expression.Constant(tolerance));
var combinedExpression = Expression.AndAlso(lessThanOrEqualExpression, toleranceExpression);
var lambda = Expression.Lambda<Func<T, bool>>(combinedExpression, parameter);
var lambda = Expression.Lambda<Func<T, bool>>(lessThanOrEqualExpression, parameter);
return source.Where(lambda);
}

View File

@ -0,0 +1,26 @@
using System;
using API.Entities.Metadata;
namespace API.Helpers.Builders;
public class ExternalSeriesMetadataBuilder : IEntityBuilder<ExternalSeriesMetadata>
{
private readonly ExternalSeriesMetadata _metadata;
public ExternalSeriesMetadata Build() => _metadata;
public ExternalSeriesMetadataBuilder()
{
_metadata = new ExternalSeriesMetadata();
}
/// <summary>
/// -1 for not set, Range 0 - 100
/// </summary>
/// <param name="rating"></param>
/// <returns></returns>
public ExternalSeriesMetadataBuilder WithAverageExternalRating(int rating)
{
_metadata.AverageExternalRating = Math.Clamp(rating, -1, 100);
return this;
}
}

View File

@ -98,4 +98,12 @@ public class SeriesBuilder : IEntityBuilder<Series>
_series.Metadata.PublicationStatus = status;
return this;
}
public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata)
{
_series.ExternalSeriesMetadata = metadata;
return this;
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@ -21,12 +22,15 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
};
}
[Obsolete]
public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag)
{
_seriesMetadata.CollectionTags ??= new List<CollectionTag>();
_seriesMetadata.CollectionTags.Add(tag);
return this;
}
[Obsolete]
public SeriesMetadataBuilder WithCollectionTags(IList<CollectionTag> tags)
{
if (tags == null) return this;
@ -34,6 +38,7 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
_seriesMetadata.CollectionTags = tags;
return this;
}
public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status)
{
_seriesMetadata.PublicationStatus = status;
@ -58,4 +63,22 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
return this;
}
public SeriesMetadataBuilder WithLanguage(string languageCode)
{
_seriesMetadata.Language = languageCode;
return this;
}
public SeriesMetadataBuilder WithReleaseYear(int year)
{
_seriesMetadata.ReleaseYear = year;
return this;
}
public SeriesMetadataBuilder WithSummary(string summary)
{
_seriesMetadata.Summary = summary;
return this;
}
}

View File

@ -1,7 +1,6 @@
import {inject, Injectable, OnDestroy} from '@angular/core';
import {inject, Injectable} from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import {Subject, tap} from 'rxjs';
import { take } from 'rxjs/operators';
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
@ -22,7 +21,6 @@ import { SeriesService } from './series.service';
import {translate} from "@jsverse/transloco";
import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {FilterService} from "./filter.service";
import {ReadingListService} from "./reading-list.service";
import {ChapterService} from "./chapter.service";
@ -468,6 +466,16 @@ export class ActionService {
});
}
async deleteMultipleChapters(seriesId: number, chapterIds: Array<Chapter>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters'))) return;
this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => {
if (callback) {
callback(true);
}
});
}
/**
* Deletes multiple collections
* @param readingLists ReadingList, should have id

View File

@ -21,6 +21,10 @@ export class ChapterService {
return this.httpClient.delete<boolean>(this.baseUrl + 'chapter?chapterId=' + chapterId);
}
deleteMultipleChapters(seriesId: number, chapterIds: Array<number>) {
return this.httpClient.post<boolean>(this.baseUrl + `chapter/delete-multiple?seriesId=${seriesId}`, {chapterIds});
}
updateChapter(chapter: Chapter) {
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
}

View File

@ -15,7 +15,6 @@
scrollbar-width: thin;
mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
//margin-top: 7px;
// For firefox
@supports (-moz-appearance:none) {

View File

@ -138,11 +138,14 @@ export class BulkSelectionService {
return ret;
}
/**
* Returns the appropriate set of supported actions for the given mix of cards
* @param callback
*/
getActions(callback: (action: ActionItem<any>, data: any) => void) {
// checks if series is present. If so, returns only series actions
// else returns volume/chapter items
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection,
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions);
}
@ -163,7 +166,8 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
// Chapter/Volume
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), [...allowedActions, Action.SendTo]);
}
private debugLog(message: string, extraData?: any) {
@ -177,18 +181,25 @@ export class BulkSelectionService {
}
private applyFilter(action: ActionItem<any>, allowedActions: Array<Action>) {
let hasValidAction = false;
let ret = false;
// Check if the current action is valid or a submenu
if (action.action === Action.Submenu || allowedActions.includes(action.action)) {
// Do something
ret = true;
hasValidAction = true;
}
if (action.children === null || action.children?.length === 0) return ret;
// If the action has children, filter them recursively
if (action.children && action.children.length > 0) {
action.children = action.children.filter((childAction) => this.applyFilter(childAction, allowedActions));
return ret;
// If no valid children remain, the parent submenu should not be considered valid
if (action.children.length === 0 && action.action === Action.Submenu) {
hasValidAction = false;
}
}
// Return whether this action or its children are valid
return hasValidAction;
}
private applyFilterToList(list: Array<ActionItem<any>>, allowedActions: Array<Action>): Array<ActionItem<any>> {

View File

@ -7,13 +7,8 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component";
import {ImageComponent} from "../shared/image/image.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
NgbDropdown,
@ -23,12 +18,8 @@ import {
NgbNav, NgbNavChangeEvent,
NgbNavContent, NgbNavItem,
NgbNavLink, NgbNavOutlet,
NgbProgressbar,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {PersonBadgeComponent} from "../shared/person-badge/person-badge.component";
import {ReviewCardComponent} from "../_single-module/review-card/review-card.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ImageService} from "../_services/image.service";
@ -38,9 +29,6 @@ import {forkJoin, map, Observable, tap} from "rxjs";
import {SeriesService} from "../_services/series.service";
import {Series} from "../_models/series";
import {AgeRating} from "../_models/metadata/age-rating";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {TimeDurationPipe} from "../_pipes/time-duration.pipe";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {LibraryType} from "../_models/library/library";
import {LibraryService} from "../_services/library.service";
import {ThemeService} from "../_services/theme.service";
@ -54,18 +42,13 @@ import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {ReadTimePipe} from "../_pipes/read-time.pipe";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service";
import {CardItemComponent} from "../cards/card-item/card-item.component";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component";
import {AgeRatingImageComponent} from "../_single-modules/age-rating-image/age-rating-image.component";
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import {
MetadataDetailRowComponent
@ -79,9 +62,7 @@ import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Device} from "../_models/device/device";
import {ActionService} from "../_services/action.service";
import {PublicationStatusPipe} from "../_pipes/publication-status.pipe";
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options";
@ -95,13 +76,8 @@ enum TabID {
selector: 'app-chapter-detail',
standalone: true,
imports: [
BulkOperationsComponent,
AsyncPipe,
CardActionablesComponent,
CarouselReelComponent,
DecimalPipe,
ExternalSeriesCardComponent,
ImageComponent,
LoadingComponent,
NgbDropdown,
NgbDropdownItem,
@ -110,18 +86,10 @@ enum TabID {
NgbNav,
NgbNavContent,
NgbNavLink,
NgbProgressbar,
NgbTooltip,
PersonBadgeComponent,
ReviewCardComponent,
SeriesCardComponent,
TagBadgeComponent,
VirtualScrollerModule,
NgStyle,
NgClass,
AgeRatingPipe,
TimeDurationPipe,
ExternalRatingComponent,
TranslocoDirective,
ReadMoreComponent,
NgbNavItem,
@ -129,19 +97,12 @@ enum TabID {
DetailsTabComponent,
RouterLink,
EntityTitleComponent,
ReadTimePipe,
DefaultValuePipe,
CardItemComponent,
RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
PublicationStatusPipe,
DatePipe,
DefaultDatePipe,
MangaFormatPipe,
CoverImageComponent
],
templateUrl: './chapter-detail.component.html',

View File

@ -13,7 +13,6 @@ import {
Component,
DestroyRef,
ElementRef,
HostListener,
Inject,
inject,
OnInit,
@ -37,7 +36,7 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {catchError, forkJoin, Observable, of, tap} from 'rxjs';
import {catchError, debounceTime, forkJoin, Observable, of, ReplaySubject, tap} from 'rxjs';
import {map} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {
@ -45,7 +44,7 @@ import {
EditSeriesModalComponent
} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Device} from 'src/app/_models/device/device';
import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
@ -247,6 +246,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
downloadInProgress: boolean = false;
nextExpectedChapter: NextExpectedChapter | undefined;
loadPageSource = new ReplaySubject<boolean>(1);
loadPage$ = this.loadPageSource.asObservable();
/**
* Track by function for Volume to tell when to refresh card data
@ -256,14 +257,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
* Track by function for Chapter to tell when to refresh card data
*/
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.minNumber}_${item.maxNumber}_${item.volumeId}_${item.pagesRead}`;
trackByRelatedSeriesIdentify = (index: number, item: RelatedSeriesPair) => `${item.series.name}_${item.series.libraryId}_${item.series.pagesRead}_${item.relation}`;
trackBySeriesIdentify = (index: number, item: Series) => `${item.name}_${item.libraryId}_${item.pagesRead}`;
trackByStoryLineIdentity = (index: number, item: StoryLineItem) => {
if (item.isChapter) {
return this.trackByChapterIdentity(index, item!.chapter!)
}
return this.trackByVolumeIdentity(index, item!.volume!);
};
/**
* Are there any related series
@ -307,7 +300,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
*/
download$: Observable<DownloadEvent | null> | null = null;
bulkActionCallback = (action: ActionItem<any>, data: any) => {
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
if (this.series === undefined) {
return;
}
@ -355,6 +348,19 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck();
});
break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(chapters.map(c => c.id), device);
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
break;
case Action.Delete:
await this.actionService.deleteMultipleChapters(seriesId, chapters, () => {
// No need to update the page as the backend will spam volume/chapter deletions
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
}
}
@ -459,6 +465,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return this.downloadService.mapToEntityType(events, this.series);
}));
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(300), tap(val => this.loadSeries(this.seriesId, val))).subscribe();
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
if (event.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
@ -469,7 +477,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ScanSeries) {
const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesScanEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId);
//this.loadSeries(this.seriesId);
this.loadPageSource.next(false);
}
} else if (event.event === EVENTS.CoverUpdate) {
const coverUpdateEvent = event.payload as CoverUpdateEvent;
@ -479,7 +488,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return;
this.loadSeries(this.seriesId, false);
//this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
}
});
@ -508,7 +518,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
}), takeUntilDestroyed(this.destroyRef)).subscribe();
this.loadSeries(this.seriesId, true);
//this.loadSeries(this.seriesId, true);
this.loadPageSource.next(true);
this.pageExtrasGroup.get('renderMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((val: PageLayoutMode | null) => {
if (val == null) return;
@ -535,12 +546,12 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
switch(action.action) {
case(Action.MarkAsRead):
this.actionService.markSeriesAsRead(series, (series: Series) => {
this.loadSeries(series.id);
this.loadPageSource.next(false);
});
break;
case(Action.MarkAsUnread):
this.actionService.markSeriesAsUnread(series, (series: Series) => {
this.loadSeries(series.id);
this.loadPageSource.next(false);
});
break;
case(Action.Scan):
@ -600,7 +611,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
case(Action.Delete):
await this.actionService.deleteVolume(volume.id, (b) => {
if (!b) return;
this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
});
break;
case(Action.AddToReadingList):
@ -1010,7 +1021,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
ref.closed.subscribe((res: EditChapterModalCloseResult) => {
if (res.success && res.isDeleted) {
this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
}
});
}
@ -1024,7 +1035,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
ref.closed.subscribe((res: EditChapterModalCloseResult) => {
if (res.success && res.isDeleted) {
this.loadSeries(this.seriesId, false);
this.loadPageSource.next(false);
}
});
}
@ -1035,9 +1046,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
modalRef.closed.subscribe((closeResult: EditSeriesModalCloseResult) => {
if (closeResult.success) {
window.scrollTo(0, 0);
this.loadSeries(this.seriesId, closeResult.updateExternal);
this.loadPageSource.next(closeResult.updateExternal);
} else if (closeResult.updateExternal) {
this.loadSeries(this.seriesId, closeResult.updateExternal);
this.loadPageSource.next(closeResult.updateExternal);
}
});
}

View File

@ -3,10 +3,12 @@
<div class="d-flex flex-column">
@if (HasCoverImage) {
<div class="mx-auto">
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}">
<app-image height="24px" width="24px" [styles]="{'background': 'none', 'max-height': '48px', 'height': '48px', 'width': '48px', 'border-radius': '50%'}"
[imageUrl]="ImageUrl"
[errorImage]="imageService.noPersonImage">
</app-image>
</a>
</div>
} @else {
<div style="background: none; max-height: 48px; height: 48px; width: 48px; border-radius: 50%" class="mx-auto">

View File

@ -8,7 +8,7 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, Location} from "@angular/common";
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ImageService} from "../_services/image.service";
import {SeriesService} from "../_services/series.service";
@ -30,7 +30,6 @@ import {
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbProgressbar,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
@ -49,19 +48,13 @@ import {LoadingComponent} from "../shared/loading/loading.component";
import {DetailsTabComponent} from "../_single-module/details-tab/details-tab.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {Person} from "../_models/metadata/person";
import {hasAnyCast, IHasCast} from "../_models/common/i-has-cast";
import {ReadTimePipe} from "../_pipes/read-time.pipe";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {IHasCast} from "../_models/common/i-has-cast";
import {EntityTitleComponent} from "../cards/entity-title/entity-title.component";
import {ImageComponent} from "../shared/image/image.component";
import {CardItemComponent} from "../cards/card-item/card-item.component";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
import {
EditVolumeModalCloseResult,
EditVolumeModalComponent
} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {Genre} from "../_models/metadata/genre";
@ -69,8 +62,6 @@ import {Tag} from "../_models/tag";
import {RelatedTabComponent} from "../_single-modules/related-tab/related-tab.component";
import {ReadingList} from "../_models/reading-list";
import {ReadingListService} from "../_services/reading-list.service";
import {AgeRatingImageComponent} from "../_single-modules/age-rating-image/age-rating-image.component";
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import {
MetadataDetailRowComponent
@ -85,8 +76,6 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
import {Device} from "../_models/device/device";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options";
@ -145,32 +134,20 @@ interface VolumeCast extends IHasCast {
NgbDropdownMenu,
NgbDropdown,
NgbDropdownToggle,
ReadTimePipe,
AgeRatingPipe,
EntityTitleComponent,
RouterLink,
NgbProgressbar,
DecimalPipe,
NgbTooltip,
ImageComponent,
NgStyle,
NgClass,
TranslocoDirective,
CardItemComponent,
VirtualScrollerModule,
ChapterCardComponent,
DefaultValuePipe,
RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
CardActionablesComponent,
BulkOperationsComponent,
DatePipe,
DefaultDatePipe,
MangaFormatPipe,
CoverImageComponent
],
templateUrl: './volume-detail.component.html',
@ -226,7 +203,7 @@ export class VolumeDetailComponent implements OnInit {
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this));
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
bulkActionCallback = (action: ActionItem<Chapter>, data: any) => {
bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
if (this.volume === null) {
return;
}
@ -256,6 +233,19 @@ export class VolumeDetailComponent implements OnInit {
this.cdRef.markForCheck();
});
break;
case Action.SendTo:
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(selectedChapterIds.map(c => c.id), device);
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
break;
case Action.Delete:
await this.actionService.deleteMultipleChapters(this.seriesId, selectedChapterIds, () => {
// No need to update the page as the backend will spam volume/chapter deletions
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
}
}
@ -609,14 +599,14 @@ export class VolumeDetailComponent implements OnInit {
});
break;
case Action.MarkAsRead:
this.actionService.markVolumeAsRead(this.seriesId, this.volume!, res => {
this.actionService.markVolumeAsRead(this.seriesId, this.volume!, _ => {
this.volume!.pagesRead = this.volume!.pages;
this.setContinuePoint();
this.cdRef.markForCheck();
});
break;
case Action.MarkAsUnread:
this.actionService.markVolumeAsUnread(this.seriesId, this.volume!, res => {
this.actionService.markVolumeAsUnread(this.seriesId, this.volume!, _ => {
this.volume!.pagesRead = 0;
this.setContinuePoint();
this.cdRef.markForCheck();

View File

@ -2397,6 +2397,7 @@
"confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?",
"alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.",
"confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.",
"confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.",
"confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.",
"confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.",
"confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.",

View File

@ -1,8 +1,10 @@
@import '../variables';
.navbar {
background-color: var(--navbar-bg-color);
color: var(--navbar-text-color);
z-index: 1040;
border-radius: 4px;
border-radius: var(--navbar-border-radius);
left: 0px;
margin: var(--navbar-header-margin);
padding: 0;
@ -20,6 +22,6 @@ i.fa.nav {
@media (max-width: $grid-breakpoints-lg) {
.navbar {
margin: 8px 12px;
margin: var(--navbar-header-mobile-x-margin) var(--navbar-header-mobile-y-margin);
}
}

View File

@ -105,8 +105,11 @@
--navbar-bg-color: black;
--navbar-text-color: white;
--navbar-fa-icon-color: white;
--navbar-border-radius: 0px; // 4px for Plex navbar
--navbar-btn-hover-outline-color: rgba(255, 255, 255, 1);
--navbar-header-margin: 0px; // 8px allows for the Plex navbar + --nav-offset: 56px;
--navbar-header-mobile-x-margin: 0px; // 8px allows for the Plex navbar
--navbar-header-mobile-y-margin: 0px; // 12px allows for the Plex navbar
/* Inputs */
--input-bg-color: #343a40;
@ -388,7 +391,7 @@
/* Bulk Selection */
--bulk-selection-text-color: var(--navbar-text-color);
--bulk-selection-highlight-text-color: var(--primary-color);
--bulk-selection-bg-color: var(--elevation-layer11-dark);
--bulk-selection-bg-color: black;
/* List Card Item */
--card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);