From 3e3b6ba92bb6035b6f7e98661347c12ad7055de8 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Wed, 20 Nov 2024 07:17:36 -0600 Subject: [PATCH] Polish 7 (#3381) --- API.Tests/AbstractDbTest.cs | 4 + API.Tests/Extensions/SeriesFilterTests.cs | 1326 ++++++++++++++++- API.Tests/Repository/SeriesRepositoryTests.cs | 2 + API/Controllers/ChapterController.cs | 85 +- API/Controllers/DeviceController.cs | 36 +- API/Controllers/SeriesController.cs | 2 +- API/DTOs/DeleteChaptersDto.cs | 8 + API/Data/Repositories/ChapterRepository.cs | 6 + API/Data/Repositories/SeriesRepository.cs | 8 +- .../Metadata/ExternalSeriesMetadata.cs | 2 +- .../QueryExtensions/Filtering/SeriesFilter.cs | 21 +- .../QueryExtensions/QueryableExtensions.cs | 72 +- .../Builders/ExternalSeriesMetadataBuilder.cs | 26 + API/Helpers/Builders/SeriesBuilder.cs | 8 + API/Helpers/Builders/SeriesMetadataBuilder.cs | 25 +- UI/Web/src/app/_services/action.service.ts | 14 +- UI/Web/src/app/_services/chapter.service.ts | 4 + UI/Web/src/app/app.component.scss | 1 - .../src/app/cards/bulk-selection.service.ts | 29 +- .../chapter-detail.component.ts | 41 +- .../series-detail/series-detail.component.ts | 55 +- .../person-badge/person-badge.component.html | 10 +- .../volume-detail/volume-detail.component.ts | 46 +- UI/Web/src/assets/langs/en.json | 1 + UI/Web/src/theme/components/_navbar.scss | 6 +- UI/Web/src/theme/themes/dark.scss | 5 +- 26 files changed, 1631 insertions(+), 212 deletions(-) create mode 100644 API/DTOs/DeleteChaptersDto.cs create mode 100644 API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index a3464db9d..9f45ca619 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -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()); var mapper = config.CreateMapper(); + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _unitOfWork = new UnitOfWork(_context, mapper, null); } diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 2774ad78e..7d88ff4fe 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -1,28 +1,1340 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.DTOs.Filtering.v2; +using API.DTOs.Progress; +using API.Entities; +using API.Entities.Enums; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Kavita.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Extensions; public class SeriesFilterTests : AbstractDbTest { - - protected override Task ResetDb() + protected override async Task ResetDb() { - return Task.CompletedTask; + _context.Series.RemoveRange(_context.Series); + _context.AppUser.RemoveRange(_context.AppUser); + await _context.SaveChangesAsync(); } + #region HasProgress + + private async Task SetupHasProgress() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + + // Create read progress on Partial and Full + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Select Partial and set pages read to 5 on first chapter + var partialSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + var partialChapter = partialSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = partialChapter.Id, + LibraryId = 1, + SeriesId = partialSeries.Id, + PageNum = 5, + VolumeId = partialChapter.VolumeId + }, user.Id)); + + // Select Full and set pages read to 10 on first chapter + var fullSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + var fullChapter = fullSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = fullChapter.Id, + LibraryId = 1, + SeriesId = fullSeries.Id, + PageNum = 10, + VolumeId = fullChapter.VolumeId + }, user.Id)); + + return user; + } + + [Fact] + public async Task HasProgress_LessThan50_ShouldReturnSingle() + { + var user = await SetupHasProgress(); + + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("None", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual50_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 50% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_GreaterThan50_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress > 50% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_Equal100_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress == 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThan100_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress < 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual100_ShouldReturnAll() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id) + .ToListAsync(); + + Assert.Equal(3, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + Assert.Contains(queryResult, s => s.Name == "Full"); + } + + [Fact] + public async Task HasProgress_LessThan100_WithProgress99_99_ShouldReturnSeries() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("AlmostFull").WithPages(100) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(100).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Set progress to 99.99% (99/100 pages read) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var chapter = series.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = chapter.Id, + LibraryId = 1, + SeriesId = series.Id, + PageNum = 99, + VolumeId = chapter.VolumeId + }, user.Id)); + + // Query series with progress < 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("AlmostFull", queryResult.First().Name); + } + #endregion + #region HasLanguage - [Fact] - public async Task HasLanguage_Works() + private async Task SetupHasLanguage() { - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List() { }).ToListAsync(); + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("English").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("en").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("French").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("fr").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Spanish").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("es").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasLanguage_Equal_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_NotEqual_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Metadata.Language == "en"); + } + + [Fact] + public async Task HasLanguage_Contains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Metadata.Language == "en"); + Assert.Contains(foundSeries, s => s.Metadata.Language == "fr"); + } + + [Fact] + public async Task HasLanguage_NotContains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("es", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_MustContains_Works() + { + await SetupHasLanguage(); + + // Since "MustContains" matches all the provided languages, no series should match in this case. + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync(); + Assert.Empty(foundSeries); + + // Single language should work. + foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_Matches_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains("en", foundSeries.Select(s => s.Metadata.Language)); + Assert.Contains("es", foundSeries.Select(s => s.Metadata.Language)); + } + + [Fact] + public async Task HasLanguage_DisabledCondition_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_EmptyLanguageList_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_UnsupportedComparison_ThrowsException() + { + await SetupHasLanguage(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync(); + }); } + #endregion + + #region HasAverageRating + + private async Task SetupHasAverageRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(-1).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(50).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(100).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAverageRating_Equal_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThan_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "Partial"); + Assert.Contains(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_LessThan_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_LessThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "None"); + Assert.Contains(series, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasAverageRating_NotEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.DoesNotContain(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_ConditionFalse_ReturnsAll() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync(); + Assert.Equal(3, series.Count); + } + + [Fact] + public async Task HasAverageRating_NotSet_IsHandled() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_ThrowsForInvalidComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync(); + }); + } + + [Fact] + public async Task HasAverageRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync(); + }); + } + + #endregion + + # region HasPublicationStatus + + private async Task SetupHasPublicationStatus() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Cancelled").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Cancelled).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("OnGoing").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.OnGoing).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Completed").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Completed).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasPublicationStatus_Equal_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("Cancelled", foundSeries[0].Name); + } + + [Fact] + public async Task HasPublicationStatus_Contains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotContains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "OnGoing"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotEqual_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List { PublicationStatus.OnGoing }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_ConditionFalse_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_EmptyPubStatuses_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForInvalidComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForOutOfRangeComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasPublicationStatus(true, (FilterComparison)999, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + #endregion + + #region HasAgeRating + private async Task SetupHasAgeRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Unknown").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("G").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Mature").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAgeRating_Equal_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("G", foundSeries[0].Name); + } + + [Fact] + public async Task HasAgeRating_Contains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Contains, new List { AgeRating.G, AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotContains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotContains, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_LessThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThan, new List { AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_LessThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_ConditionFalse_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(false, FilterComparison.Equal, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_EmptyRatings_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_ThrowsForInvalidComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List { AgeRating.G }).ToListAsync(); + }); + } + + [Fact] + public async Task HasAgeRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAgeRating(true, (FilterComparison)999, new List { AgeRating.G }).ToListAsync(); + }); + } + + #endregion + + #region HasReleaseYear + + private async Task SetupHasReleaseYear() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("2000").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2000).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2020").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2020).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2025").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2025).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasReleaseYear_Equal_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("2020", foundSeries[0].Name); + } + + [Fact] + public async Task HasReleaseYear_GreaterThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2020"); + Assert.Contains(foundSeries, s => s.Name == "2025"); + } + + [Fact] + public async Task HasReleaseYear_LessThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2000"); + Assert.Contains(foundSeries, s => s.Name == "2020"); + } + + [Fact] + public async Task HasReleaseYear_IsInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsNotInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync(); + Assert.Single(foundSeries); + Assert.Contains(foundSeries, s => s.Name == "2000"); + } + + [Fact] + public async Task HasReleaseYear_ConditionFalse_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_ReleaseYearNull_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsEmpty_Works() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("EmptyYear").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(0).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("EmptyYear", foundSeries[0].Name); + } + + + #endregion + + #region HasRating + + private async Task SetupHasRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("No Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("0 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("4.5 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + + var seriesService = new SeriesService(_unitOfWork, Substitute.For(), + Substitute.For(), Substitute.For>(), + Substitute.For(), Substitute.For()); + + // Select 0 Rating + var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + + Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + { + SeriesId = zeroRating.Id, + UserRating = 0 + })); + + // Select 4.5 Rating + var partialRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + + Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + { + SeriesId = partialRating.Id, + UserRating = 4.5f + })); + + return user; + } + + [Fact] + public async Task HasRating_Equal_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.Equal, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.GreaterThan, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.LessThan, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_IsEmpty_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.IsEmpty, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.GreaterThanEqual, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.LessThanEqual, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + #endregion + + #region HasAverageReadTime + + + + #endregion + + #region HasReadLast + + + + #endregion + + #region HasReadingDate + + + + #endregion + + #region HasTags + + + + #endregion + + #region HasPeople + + + + #endregion + + #region HasGenre + + + + #endregion + + #region HasFormat + + + + #endregion + + #region HasCollectionTags + + + + #endregion + + #region HasName + + private async Task SetupHasName() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Don't Toy With Me, Miss Nagatoro").WithLocalizedName("Ijiranaide, Nagatoro-san").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("My Dress-Up Darling").WithLocalizedName("Sono Bisque Doll wa Koi wo Suru").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasName_Equal_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Equal, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Equal_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Equal, "Ijiranaide, Nagatoro-san") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.BeginsWith, "My Dress") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.BeginsWith, "Sono Bisque") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_EndsWith_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.EndsWith, "Nagatoro") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Matches_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Matches, "Toy With Me") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_NotEqual_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.NotEqual, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Equal(2, foundSeries.Count); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + + #endregion + + #region HasSummary + + private async Task SetupHasSummary() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Hippos").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like hippos").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Apples").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like apples").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Ducks").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like ducks").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("No Summary").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasSummary_Equal_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.Equal, "I like hippos") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_BeginsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.BeginsWith, "I like h") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_EndsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.EndsWith, "apples") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Apples", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_Matches_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.Matches, "like ducks") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Ducks", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_NotEqual_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.NotEqual, "I like ducks") + .ToListAsync(); + + Assert.Equal(3, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Name == "Ducks"); + } + + [Fact] + public async Task HasSummary_IsEmpty_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.IsEmpty, string.Empty) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Summary", foundSeries[0].Name); + } + + #endregion + + + #region HasPath + + + + #endregion + + + #region HasFilePath + + + #endregion } diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index ec4b2a9f5..73ed58a5a 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -159,4 +159,6 @@ public class SeriesRepositoryTests } } + // TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck) + } diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 3b1746621..9f2fc4c46 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -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 _logger; - public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger) { _unitOfWork = unitOfWork; _localizationService = localizationService; _eventHub = eventHub; + _logger = logger; } /// @@ -84,6 +88,83 @@ public class ChapterController : BaseApiController return Ok(true); } + /// + /// Deletes multiple chapters and any volumes with no leftover chapters + /// + /// The ID of the series + /// The IDs of the chapters to be deleted + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("delete-multiple")] + public async Task> 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(); + + 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")); + } + + } + + /// /// Update chapter metadata /// diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index cfd3c3416..8c8081d98 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -110,18 +110,18 @@ public class DeviceController : BaseApiController [HttpPost("send-to")] public async Task 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 } - + /// + /// Attempts to send a whole series to a device. + /// + /// + /// [HttpPost("send-series-to")] public async Task 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")); } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index d7194bf40..2cf97d9b6 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -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")] diff --git a/API/DTOs/DeleteChaptersDto.cs b/API/DTOs/DeleteChaptersDto.cs new file mode 100644 index 000000000..cbd21df36 --- /dev/null +++ b/API/DTOs/DeleteChaptersDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public class DeleteChaptersDto +{ + public IList ChapterIds { get; set; } = default!; +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 21eee7d31..52ded9e94 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -31,6 +31,7 @@ public interface IChapterRepository { void Update(Chapter chapter); void Remove(Chapter chapter); + void Remove(IList chapters); Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); @@ -68,6 +69,11 @@ public class ChapterRepository : IChapterRepository _context.Chapter.Remove(chapter); } + public void Remove(IList chapters) + { + _context.Chapter.RemoveRange(chapters); + } + public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index d0183ae66..cad70c3eb 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -696,7 +696,7 @@ public class SeriesRepository : ISeriesRepository var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() + //.AsSplitQuery() .AsNoTracking(); return await PagedList.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> ApplyCollectionFilter(FilterV2Dto filter, IQueryable query, int userId, AgeRestriction userRating) diff --git a/API/Entities/Metadata/ExternalSeriesMetadata.cs b/API/Entities/Metadata/ExternalSeriesMetadata.cs index 215a01585..598d02184 100644 --- a/API/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/API/Entities/Metadata/ExternalSeriesMetadata.cs @@ -21,7 +21,7 @@ public class ExternalSeriesMetadata public ICollection ExternalRecommendations { get; set; } = null!; /// - /// Average External Rating. -1 means not set + /// Average External Rating. -1 means not set, 0 - 100 /// public int AverageExternalRating { get; set; } = 0; diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index 90c691df2..822a859c5 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -14,6 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering; public static class SeriesFilter { private const float FloatingPointTolerance = 0.001f; + public static IQueryable HasLanguage(this IQueryable queryable, bool condition, FilterComparison comparison, IList 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)); } diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index c4fad72ab..571e9430c 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -113,109 +113,55 @@ public static class QueryableExtensions return condition ? queryable.Where(predicate) : queryable; } - public static IQueryable WhereLike(this IQueryable queryable, bool condition, Expression> 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>(likeExpression, propertySelector.Parameters[0]); - - return queryable.Where(lambda); - } public static IQueryable WhereGreaterThan(this IQueryable source, Expression> 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>(combinedExpression, parameter); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); return source.Where(lambda); } public static IQueryable WhereGreaterThanOrEqual(this IQueryable source, Expression> 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>(combinedExpression, parameter); + var greaterThanExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); return source.Where(lambda); } public static IQueryable WhereLessThan(this IQueryable source, Expression> 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>(combinedExpression, parameter); + var lambda = Expression.Lambda>(lessThanExpression, parameter); return source.Where(lambda); } public static IQueryable WhereLessThanOrEqual(this IQueryable source, Expression> 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>(combinedExpression, parameter); + var lambda = Expression.Lambda>(lessThanOrEqualExpression, parameter); return source.Where(lambda); } diff --git a/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs new file mode 100644 index 000000000..e716f5927 --- /dev/null +++ b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs @@ -0,0 +1,26 @@ +using System; +using API.Entities.Metadata; + +namespace API.Helpers.Builders; + +public class ExternalSeriesMetadataBuilder : IEntityBuilder +{ + private readonly ExternalSeriesMetadata _metadata; + public ExternalSeriesMetadata Build() => _metadata; + + public ExternalSeriesMetadataBuilder() + { + _metadata = new ExternalSeriesMetadata(); + } + + /// + /// -1 for not set, Range 0 - 100 + /// + /// + /// + public ExternalSeriesMetadataBuilder WithAverageExternalRating(int rating) + { + _metadata.AverageExternalRating = Math.Clamp(rating, -1, 100); + return this; + } +} diff --git a/API/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index e11de7636..525b0cddc 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -98,4 +98,12 @@ public class SeriesBuilder : IEntityBuilder _series.Metadata.PublicationStatus = status; return this; } + + public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata) + { + _series.ExternalSeriesMetadata = metadata; + return this; + } + + } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 703957c86..316eaaf83 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -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 }; } + [Obsolete] public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag) { _seriesMetadata.CollectionTags ??= new List(); _seriesMetadata.CollectionTags.Add(tag); return this; } + + [Obsolete] public SeriesMetadataBuilder WithCollectionTags(IList tags) { if (tags == null) return this; @@ -34,6 +38,7 @@ public class SeriesMetadataBuilder : IEntityBuilder _seriesMetadata.CollectionTags = tags; return this; } + public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) { _seriesMetadata.PublicationStatus = status; @@ -58,4 +63,22 @@ public class SeriesMetadataBuilder : IEntityBuilder 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; + } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 1d214cd98..27e38f82f 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -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, 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 diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts index 7a91eb0cf..c722031bd 100644 --- a/UI/Web/src/app/_services/chapter.service.ts +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -21,6 +21,10 @@ export class ChapterService { return this.httpClient.delete(this.baseUrl + 'chapter?chapterId=' + chapterId); } + deleteMultipleChapters(seriesId: number, chapterIds: Array) { + return this.httpClient.post(this.baseUrl + `chapter/delete-multiple?seriesId=${seriesId}`, {chapterIds}); + } + updateChapter(chapter: Chapter) { return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); } diff --git a/UI/Web/src/app/app.component.scss b/UI/Web/src/app/app.component.scss index 280354da4..bc68e8372 100644 --- a/UI/Web/src/app/app.component.scss +++ b/UI/Web/src/app/app.component.scss @@ -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) { diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index 0572847ed..ff80a0288 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -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, 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, allowedActions: Array) { + 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)); - action.children = action.children.filter((childAction) => this.applyFilter(childAction, allowedActions)); + // 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 ret; + // Return whether this action or its children are valid + return hasValidAction; } private applyFilterToList(list: Array>, allowedActions: Array): Array> { diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index c1e3482a5..72eb9d975 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -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', diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 551b49620..c3d92fc47 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -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(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 | null = null; - bulkActionCallback = (action: ActionItem, data: any) => { + bulkActionCallback = async (action: ActionItem, 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); } }); } diff --git a/UI/Web/src/app/shared/person-badge/person-badge.component.html b/UI/Web/src/app/shared/person-badge/person-badge.component.html index c6beb422f..9a7b6f104 100644 --- a/UI/Web/src/app/shared/person-badge/person-badge.component.html +++ b/UI/Web/src/app/shared/person-badge/person-badge.component.html @@ -3,10 +3,12 @@
@if (HasCoverImage) {
- - + + + +
} @else {
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index fb11c93e1..0797fd7de 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -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> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this)); chapterActions: Array> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); - bulkActionCallback = (action: ActionItem, data: any) => { + bulkActionCallback = async (action: ActionItem, _: 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(); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 228b8d0fb..9ccf35c33 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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.", diff --git a/UI/Web/src/theme/components/_navbar.scss b/UI/Web/src/theme/components/_navbar.scss index efde59f88..73244bdc7 100644 --- a/UI/Web/src/theme/components/_navbar.scss +++ b/UI/Web/src/theme/components/_navbar.scss @@ -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); } } diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index cb47a4c59..4edab2dc4 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -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%);