From 154feb1c27502bd304c1c84773cb7e62fcd31a64 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Thu, 12 Jun 2025 19:14:37 -0500 Subject: [PATCH] Finally got the refactor completed, now the code needs major streamlining. All the testing checks out. --- API/DTOs/Filtering/v2/FilterField.cs | 9 +- API/DTOs/Filtering/v2/FilterStatementDto.cs | 11 +- .../Browse/Requests/BrowsePersonFilterDto.cs | 18 +- API/Data/Repositories/PersonRepository.cs | 94 ++++++-- .../QueryExtensions/Filtering/PersonFilter.cs | 136 ++++++++++++ .../PersonFilterFieldValueConverter.cs | 31 +++ .../src/app/_models/metadata/v2/filter-v2.ts | 2 +- .../metadata/v2/person-filter-field.ts | 5 +- UI/Web/src/app/_pipes/browse-title.pipe.ts | 4 - .../app/_pipes/generic-filter-field.pipe.ts | 102 ++++++++- UI/Web/src/app/_pipes/sort-field.pipe.ts | 30 ++- .../src/app/_resolvers/url-filter.resolver.ts | 22 ++ .../app/_routes/all-series-routing.module.ts | 12 +- .../app/_routes/bookmark-routing.module.ts | 12 +- .../src/app/_routes/browse-routing.module.ts | 8 +- .../app/_routes/collections-routing.module.ts | 14 +- .../_routes/library-detail-routing.module.ts | 11 +- .../_routes/want-to-read-routing.module.ts | 10 +- UI/Web/src/app/_services/metadata.service.ts | 47 +++- UI/Web/src/app/_services/person.service.ts | 17 +- UI/Web/src/app/_services/toggle.service.ts | 11 +- .../all-series/all-series.component.ts | 21 +- .../bookmarks/bookmarks.component.ts | 28 ++- .../browse-authors.component.html | 75 +++---- .../browse-people/browse-authors.component.ts | 85 ++++---- .../card-detail-layout.component.html | 8 +- .../card-detail-layout.component.ts | 26 ++- .../collection-detail.component.ts | 24 ++- .../library-detail.component.ts | 19 +- .../metadata-builder.component.ts | 11 +- .../metadata-filter-row.component.html | 4 +- .../metadata-filter-row.component.ts | 161 ++++++++------ .../app/metadata-filter/filter-settings.ts | 2 + .../metadata-filter.component.html | 14 +- .../metadata-filter.component.ts | 79 ++++--- .../_services/filter-utilities.service.ts | 203 ++++++++++++++++-- .../want-to-read/want-to-read.component.ts | 22 +- UI/Web/src/assets/langs/en.json | 12 +- 38 files changed, 1084 insertions(+), 316 deletions(-) create mode 100644 API/Extensions/QueryExtensions/Filtering/PersonFilter.cs create mode 100644 API/Helpers/Converters/PersonFilterFieldValueConverter.cs create mode 100644 UI/Web/src/app/_resolvers/url-filter.resolver.ts diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 5323f2b48..246a92a90 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -56,5 +56,12 @@ public enum FilterField /// Last time User Read /// ReadLast = 32, - +} + +public enum PersonFilterField +{ + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index ebe6d16af..8c99bd24c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; + +namespace API.DTOs.Filtering.v2; public sealed record FilterStatementDto { @@ -6,3 +8,10 @@ public sealed record FilterStatementDto public FilterField Field { get; set; } public string Value { get; set; } } + +public sealed record PersonFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public PersonFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs index cfa9a4236..d41cf37f3 100644 --- a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs +++ b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.Entities.Enums; namespace API.DTOs.Metadata.Browse.Requests; @@ -7,7 +8,20 @@ namespace API.DTOs.Metadata.Browse.Requests; public sealed record BrowsePersonFilterDto { - public required List Roles { get; set; } - public string? Query { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; public PersonSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 2aa4368a7..86d2f5a06 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs; using API.DTOs.Filtering.v2; using API.DTOs.Metadata.Browse; @@ -11,7 +12,9 @@ using API.Entities.Enums; using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -201,33 +204,78 @@ public class PersonRepository : IPersonRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = _context.Person - .Where(p => p.SeriesMetadataPeople.Any(smp => filter.Roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => filter.Roles.Contains(cmp.Role))) - .WhereIf(!string.IsNullOrEmpty(filter.Query), p => EF.Functions.Like(p.Name, $"%{filter.Query}%")) - .RestrictAgainstAgeRestriction(ageRating) - .SortBy(filter.SortOptions) - .Select(p => new BrowsePersonDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - CoverImage = p.CoverImage, - SeriesCount = p.SeriesMetadataPeople - .Where(smp => filter.Roles.Contains(smp.Role)) - .Select(smp => smp.SeriesMetadata.SeriesId) - .Distinct() - .Count(), - ChapterCount = p.ChapterPeople - .Where(cp => filter.Roles.Contains(cp.Role)) - .Select(cp => cp.Chapter.Id) - .Distinct() - .Count() - }) - ; + var query = CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + private IQueryable CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + { + var query = _context.Person.AsNoTracking(); + + // Apply filtering based on statements + query = BuildPersonFilterQuery(userId, filter, query); + + // Apply age restriction + query = query.RestrictAgainstAgeRestriction(ageRating); + + // Apply sorting and limiting + var sortedQuery = query.SortBy(filter.SortOptions); + + var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); + + // Project to DTO + var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count(), + ChapterCount = p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() + }); + + return projectedQuery; + } + + private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) + { + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; + + var queries = filterDto.Statements + .Select(statement => BuildPersonFilterGroup(userId, statement, query)) + .ToList(); + + return filterDto.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable query) + { + var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value), + PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList)value), + PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value), + PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value), + _ => throw new ArgumentOutOfRangeException() + }; + } + + private static IQueryable ApplyPersonLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) diff --git a/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs new file mode 100644 index 000000000..c36164d9d --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Entities.Person; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class PersonFilter +{ + public static IQueryable HasPersonName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)), + FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")), + FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")), + FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")), + FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString), + FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual + or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast or FilterComparison.MustContains + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Name"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + public static IQueryable HasPersonRole(this IQueryable queryable, bool condition, + FilterComparison comparison, IList roles) + { + if (roles == null || roles.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p => + p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || + p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.NotContains => queryable.Where(p => + !p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) && + !p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith + or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan + or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Role"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonSeriesCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.SeriesCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonChapterCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.ChapterCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } +} diff --git a/API/Helpers/Converters/PersonFilterFieldValueConverter.cs b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs new file mode 100644 index 000000000..4165be4c4 --- /dev/null +++ b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.Helpers.Converters; + +public static class PersonFilterFieldValueConverter +{ + public static object ConvertValue(PersonFilterField field, string value) + { + return field switch + { + PersonFilterField.Name => value, + PersonFilterField.Role => ParsePersonRoles(value), + PersonFilterField.SeriesCount => int.Parse(value), + PersonFilterField.ChapterCount => int.Parse(value), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + + private static IList ParsePersonRoles(string value) + { + if (string.IsNullOrEmpty(value)) return new List(); + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => Enum.Parse(v.Trim())) + .ToList(); + } +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts index d93ee6f08..77c064450 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts @@ -2,7 +2,7 @@ import {FilterStatement} from "./filter-statement"; import {FilterCombination} from "./filter-combination"; import {SortOptions} from "./sort-options"; -export interface FilterV2 { +export interface FilterV2 { name?: string; statements: Array>; combination: FilterCombination; diff --git a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts index 2351627e9..6bfb5a0c1 100644 --- a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts @@ -1,7 +1,8 @@ export enum PersonFilterField { - None = -1, Role = 1, - Name = 2 + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts index a6256f065..0495e8b8a 100644 --- a/UI/Web/src/app/_pipes/browse-title.pipe.ts +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -2,10 +2,6 @@ import {Pipe, PipeTransform} from '@angular/core'; import {FilterField} from "../_models/metadata/v2/filter-field"; import {translate} from "@jsverse/transloco"; - -// export type BrowseTitleFields = FilterField.Genres | FilterField.Tags | FilterField.Editor | FilterField.Inker | -// FilterField. - /** * Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page * Example: Genre & "Action" -> Browse Action diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts index 8b326aafe..f342c0034 100644 --- a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -1,12 +1,108 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; @Pipe({ name: 'genericFilterField' }) export class GenericFilterFieldPipe implements PipeTransform { - transform(value: unknown, ...args: unknown[]): unknown { - return null; + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.translateFilterField(value as FilterField); + case "person": + return this.translatePersonFilterField(value as PersonFilterField); + } + } + + private translatePersonFilterField(value: PersonFilterField) { + switch (value) { + case PersonFilterField.Role: + return translate('generic-filter-field-pipe.person-role'); + case PersonFilterField.Name: + return translate('generic-filter-field-pipe.person-name'); + case PersonFilterField.SeriesCount: + return translate('generic-filter-field-pipe.person-series-count'); + case PersonFilterField.ChapterCount: + return translate('generic-filter-field-pipe.person-chapter-count'); + } + } + + private translateFilterField(value: FilterField) { + switch (value) { + case FilterField.AgeRating: + return translate('filter-field-pipe.age-rating'); + case FilterField.Characters: + return translate('filter-field-pipe.characters'); + case FilterField.CollectionTags: + return translate('filter-field-pipe.collection-tags'); + case FilterField.Colorist: + return translate('filter-field-pipe.colorist'); + case FilterField.CoverArtist: + return translate('filter-field-pipe.cover-artist'); + case FilterField.Editor: + return translate('filter-field-pipe.editor'); + case FilterField.Formats: + return translate('filter-field-pipe.formats'); + case FilterField.Genres: + return translate('filter-field-pipe.genres'); + case FilterField.Inker: + return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); + case FilterField.Languages: + return translate('filter-field-pipe.languages'); + case FilterField.Libraries: + return translate('filter-field-pipe.libraries'); + case FilterField.Letterer: + return translate('filter-field-pipe.letterer'); + case FilterField.PublicationStatus: + return translate('filter-field-pipe.publication-status'); + case FilterField.Penciller: + return translate('filter-field-pipe.penciller'); + case FilterField.Publisher: + return translate('filter-field-pipe.publisher'); + case FilterField.ReadProgress: + return translate('filter-field-pipe.read-progress'); + case FilterField.ReadTime: + return translate('filter-field-pipe.read-time'); + case FilterField.ReleaseYear: + return translate('filter-field-pipe.release-year'); + case FilterField.SeriesName: + return translate('filter-field-pipe.series-name'); + case FilterField.Summary: + return translate('filter-field-pipe.summary'); + case FilterField.Tags: + return translate('filter-field-pipe.tags'); + case FilterField.Translators: + return translate('filter-field-pipe.translators'); + case FilterField.UserRating: + return translate('filter-field-pipe.user-rating'); + case FilterField.Writers: + return translate('filter-field-pipe.writers'); + case FilterField.Path: + return translate('filter-field-pipe.path'); + case FilterField.FilePath: + return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); + case FilterField.ReadingDate: + return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); + case FilterField.AverageRating: + return translate('filter-field-pipe.average-rating'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } } } diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index 13ff4f758..b4b294c0a 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Pipe({ name: 'sortField', @@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform { constructor(private translocoService: TranslocoService) { } - transform(value: SortField): string { + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.seriesSortFields(value as SortField); + case "person": + return this.personSortFields(value as PersonSortField); + + } + } + + private personSortFields(value: PersonSortField) { + switch (value) { + case PersonSortField.Name: + return this.translocoService.translate('sort-field-pipe.person-name'); + case PersonSortField.SeriesCount: + return this.translocoService.translate('sort-field-pipe.person-series-count'); + case PersonSortField.ChapterCount: + return this.translocoService.translate('sort-field-pipe.person-chapter-count'); + + } + } + + private seriesSortFields(value: SortField) { switch (value) { case SortField.SortName: return this.translocoService.translate('sort-field-pipe.sort-name'); @@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform { case SortField.Random: return this.translocoService.translate('sort-field-pipe.random'); } - } } diff --git a/UI/Web/src/app/_resolvers/url-filter.resolver.ts b/UI/Web/src/app/_resolvers/url-filter.resolver.ts new file mode 100644 index 000000000..16bc5c752 --- /dev/null +++ b/UI/Web/src/app/_resolvers/url-filter.resolver.ts @@ -0,0 +1,22 @@ +import {Injectable} from "@angular/core"; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router"; +import {Observable, of} from "rxjs"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + +/** + * Checks the url for a filter and resolves one if applicable, otherwise returns null. + * It is up to the consumer to cast appropriately. + */ +@Injectable({ + providedIn: 'root' +}) +export class UrlFilterResolver implements Resolve { + + constructor(private filterUtilitiesService: FilterUtilitiesService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + if (!state.url.includes('?')) return of(null); + return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]); + } +} diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index d9dfaaf96..5c4804251 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -1,7 +1,13 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {Routes} from "@angular/router"; +import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: AllSeriesComponent, pathMatch: 'full'}, + {path: '', component: AllSeriesComponent, pathMatch: 'full', + runGuardsAndResolvers: 'always', + resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 6da971e08..2c7c52036 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -1,6 +1,12 @@ -import { Routes } from "@angular/router"; -import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component"; +import {Routes} from "@angular/router"; +import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: BookmarksComponent, pathMatch: 'full'}, + {path: '', component: BookmarksComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts index 48d468fc1..ed736ff12 100644 --- a/UI/Web/src/app/_routes/browse-routing.module.ts +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -2,10 +2,16 @@ import {Routes} from "@angular/router"; import {BrowseAuthorsComponent} from "../browse/browse-people/browse-authors.component"; import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: 'authors', component: BrowseAuthorsComponent, pathMatch: 'full'}, + {path: 'authors', component: BrowseAuthorsComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, ]; diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 80510c8f6..2b3b0ffd7 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -1,9 +1,15 @@ -import { Routes } from '@angular/router'; -import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component'; -import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component'; +import {Routes} from '@angular/router'; +import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; +import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent}, + {path: ':id', component: CollectionDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 7f2f4150c..3c09a71ee 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -2,6 +2,7 @@ import {Routes} from '@angular/router'; import {AuthGuard} from '../_guards/auth.guard'; import {LibraryAccessGuard} from '../_guards/library-access.guard'; import {LibraryDetailComponent} from '../library-detail/library-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ @@ -9,12 +10,18 @@ export const routes: Routes = [ path: ':libraryId', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, { path: '', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b3301d9f9..b593172c0 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -1,6 +1,10 @@ -import { Routes } from '@angular/router'; -import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {Routes} from '@angular/router'; +import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full'}, + {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index bb57808e5..112f17999 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -7,7 +7,7 @@ import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {Language} from '../_models/metadata/language'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; -import {Person, PersonRole} from '../_models/metadata/person'; +import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; @@ -29,6 +29,10 @@ import {PaginatedResult} from "../_models/pagination"; import {UtilityService} from "../shared/_services/utility.service"; import {BrowseGenre} from "../_models/metadata/browse/browse-genre"; import {BrowseTag} from "../_models/metadata/browse/browse-tag"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonRolePipe} from "../_pipes/person-role.pipe"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -44,6 +48,7 @@ export class MetadataService { private validLanguages: Array = []; private ageRatingPipe = new AgeRatingPipe(); private mangaFormatPipe = new MangaFormatPipe(this.translocoService); + private personRolePipe = new PersonRolePipe(); constructor(private httpClient: HttpClient) { } @@ -148,19 +153,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + 'metadata/people-by-role?role=' + role); } - createDefaultFilterDto(): FilterV2 { + createDefaultFilterDto(entityType: ValidFilterEntity): FilterV2 { return { - statements: [] as FilterStatement[], + statements: [] as FilterStatement[], combination: FilterCombination.And, limitTo: 0, sortOptions: { isAscending: true, - sortField: SortField.SortName + sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort } }; } - createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + createDefaultFilterStatement(entityType: ValidFilterEntity) { + switch (entityType) { + case 'series': + return this.createFilterStatement(FilterField.SeriesName); + case 'person': + return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist}, ${PersonRole.Writer}`); + } + } + + createFilterStatement(field: T, comparison = FilterComparison.Equal, value = '') { return { comparison: comparison, field: field, @@ -223,9 +237,28 @@ export class MetadataService { /** * Used to get the underlying Options (for Metadata Filter Dropdowns) * @param filterField + * @param entityType */ - getOptionsForFilterField(filterField: FilterField) { - switch (filterField) { + getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { + + switch (entityType) { + case 'series': + return this.getSeriesOptionsForFilterField(filterField as FilterField); + case 'person': + return this.getPersonOptionsForFilterField(filterField as PersonFilterField); + } + } + + private getPersonOptionsForFilterField(field: PersonFilterField) { + switch (field) { + case PersonFilterField.Role: + return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}})); + } + return of([]) + } + + private getSeriesOptionsForFilterField(field: FilterField) { + switch (field) { case FilterField.PublicationStatus: return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { return {value: pub.value, label: pub.title} diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index eb4ff1dfd..fc9148135 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -9,7 +9,9 @@ import {UtilityService} from "../shared/_services/utility.service"; import {BrowsePerson} from "../_models/metadata/browse/browse-person"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; -import {BrowsePersonFilter} from "../_models/metadata/v2/browse-person-filter"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -44,7 +46,7 @@ export class PersonService { return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); } - getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + getAuthorsToBrowse(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -55,6 +57,17 @@ export class PersonService { ); } + // getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + // let params = new HttpParams(); + // params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + // + // return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( + // map((response: any) => { + // return this.utilityService.createPaginatedResult(response) as PaginatedResult; + // }) + // ); + // } + downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts index 8b335394a..b02ece82d 100644 --- a/UI/Web/src/app/_services/toggle.service.ts +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; -import { filter, ReplaySubject, take } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {NavigationStart, Router} from '@angular/router'; +import {filter, ReplaySubject, take} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -18,6 +18,7 @@ export class ToggleService { .pipe(filter(event => event instanceof NavigationStart)) .subscribe((event) => { this.toggleState = false; + console.log('[toggleservice] collapsing toggle due to navigation event'); this.toggleStateSource.next(this.toggleState); }); this.toggleStateSource.next(false); @@ -27,13 +28,15 @@ export class ToggleService { this.toggleState = !this.toggleState; this.toggleStateSource.pipe(take(1)).subscribe(state => { this.toggleState = !state; + console.log('[toggleservice] toggling setting filter open status: ', this.toggleState); this.toggleStateSource.next(this.toggleState); }); - + } set(state: boolean) { this.toggleState = state; + console.log('[toggleservice] setting filter open status: ', this.toggleState); this.toggleStateSource.next(state); } } diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index c53c58e8e..979781878 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -38,6 +38,8 @@ import {MetadataService} from "../../../_services/metadata.service"; import {Observable} from "rxjs"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {Select2Option} from "ng-select2-component"; @Component({ @@ -130,8 +132,14 @@ export class AllSeriesComponent implements OnInit { constructor() { this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement); + } this.title = this.route.snapshot.queryParamMap.get('title') || this.filter!.name || this.title; this.titleService.setTitle('Kavita - ' + this.title); @@ -140,8 +148,13 @@ export class AllSeriesComponent implements OnInit { if (this.shouldRewriteTitle()) { const field = this.filter!.statements[0].field; + console.log('field', field); + // This api returns value as string and number, it will complain without the casting - (this.metadataService.getOptionsForFilterField(field) as Observable).subscribe((opts: any[]) => { + (this.metadataService.getOptionsForFilterField(field, 'series') as Observable).subscribe((opts: Select2Option[]) => { + console.log('opts:', opts); + + // BUG: There is now a timing issue when navigating FROM a click. On refresh it works. const matchingOpts = opts.filter(m => `${m.value}` === `${this.filter!.statements[0].value}`); if (matchingOpts.length === 0) return; @@ -158,7 +171,7 @@ export class AllSeriesComponent implements OnInit { this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); - this.filterSettings.presetsV2 = this.filter; + this.filterSettings.presetsV2 = this.filter; this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts index 8f22e9e45..985515bb6 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts @@ -1,4 +1,12 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + inject, + OnInit +} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs'; @@ -10,7 +18,7 @@ import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {PageBookmark} from 'src/app/_models/readers/page-bookmark'; import {Pagination} from 'src/app/_models/pagination'; import {Series} from 'src/app/_models/series'; -import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ImageService} from 'src/app/_services/image.service'; import {JumpbarService} from 'src/app/_services/jumpbar.service'; @@ -28,6 +36,9 @@ import {Title} from "@angular/platform-browser"; import {WikiLink} from "../../../_models/wiki"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {MetadataService} from "../../../_services/metadata.service"; @Component({ selector: 'app-bookmarks', @@ -52,6 +63,8 @@ export class BookmarksComponent implements OnInit { private readonly titleService = inject(Title); public readonly bulkSelectionService = inject(BulkSelectionService); public readonly imageService = inject(ImageService); + public readonly metadataService = inject(MetadataService); + public readonly destroyRef = inject(DestroyRef); protected readonly WikiLink = WikiLink; @@ -74,8 +87,14 @@ export class BookmarksComponent implements OnInit { refresh: EventEmitter = new EventEmitter(); constructor() { - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement); + } this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); @@ -85,6 +104,7 @@ export class BookmarksComponent implements OnInit { this.cdRef.markForCheck(); }); + this.titleService.setTitle('Kavita - ' + translate('bookmarks.title')); } diff --git a/UI/Web/src/app/browse/browse-people/browse-authors.component.html b/UI/Web/src/app/browse/browse-people/browse-authors.component.html index bd6f18e2d..94b1c0e1b 100644 --- a/UI/Web/src/app/browse/browse-people/browse-authors.component.html +++ b/UI/Web/src/app/browse/browse-people/browse-authors.component.html @@ -8,44 +8,44 @@ -
-
-
-
- - - -
-
+ + + + + + + + + + + + + + + + -
-
- - -
-
- -
-
- - - -
-
-
-
+ + + + + + + + + + + + + + + + + + + + diff --git a/UI/Web/src/app/browse/browse-people/browse-authors.component.ts b/UI/Web/src/app/browse/browse-people/browse-authors.component.ts index 4c3dad371..7a04e6b3f 100644 --- a/UI/Web/src/app/browse/browse-people/browse-authors.component.ts +++ b/UI/Web/src/app/browse/browse-people/browse-authors.component.ts @@ -23,19 +23,16 @@ import {PersonCardComponent} from "../../cards/person-card/person-card.component import {ImageService} from "../../_services/image.service"; import {TranslocoDirective} from "@jsverse/transloco"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; -import {allPeopleRoles, PersonRole} from "../../_models/metadata/person"; -import {Select2} from "ng-select2-component"; -import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {PersonRolePipe} from "../../_pipes/person-role.pipe"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {debounceTime, tap} from "rxjs/operators"; -import {SortButtonComponent} from "../../_single-module/sort-button/sort-button.component"; +import {ReactiveFormsModule} from "@angular/forms"; import {PersonSortField} from "../../_models/metadata/v2/person-sort-field"; -import {PersonSortOptions} from "../../_models/metadata/v2/sort-options"; import {PersonFilterField} from "../../_models/metadata/v2/person-filter-field"; import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; import {FilterV2} from "../../_models/metadata/v2/filter-v2"; import {PersonFilterSettings} from "../../metadata-filter/filter-settings"; +import {FilterEvent} from "../../_models/metadata/series-filter"; +import {PersonRole} from "../../_models/metadata/person"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ @@ -47,9 +44,8 @@ import {PersonFilterSettings} from "../../metadata-filter/filter-settings"; DecimalPipe, PersonCardComponent, CompactNumberPipe, - Select2, ReactiveFormsModule, - SortButtonComponent, + ], templateUrl: './browse-authors.component.html', styleUrl: './browse-authors.component.scss', @@ -75,58 +71,51 @@ export class BrowseAuthorsComponent implements OnInit { refresh: EventEmitter = new EventEmitter(); jumpKeys: Array = []; trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; - personRolePipe = new PersonRolePipe(); - allRoles = allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}}); - filterGroup = new FormGroup({ - roles: new FormControl([PersonRole.CoverArtist, PersonRole.Writer], []), - sortField: new FormControl(PersonSortField.Name, []), - query: new FormControl('', []), - }); - isAscending: boolean = true; filterSettings: PersonFilterSettings = new PersonFilterSettings(); filterActive: boolean = false; filterOpen: EventEmitter = new EventEmitter(); - filter: FilterV2 | undefined = undefined; + filter: FilterV2 | undefined = undefined; filterActiveCheck!: FilterV2; - ngOnInit() { + constructor() { this.isLoading = true; this.cdRef.markForCheck(); + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + // if (this.filter == null) { + // this.filter + // } + + console.log('filter from url:', this.filter); this.filterActiveCheck = this.filterUtilityService.createPersonV2Filter(); - this.filterActiveCheck!.statements.push(this.filterUtilityService.createPersonV2DefaultStatement()); - this.filterSettings.presetsV2 = this.filter; + this.filterActiveCheck!.statements.push({value: `${PersonRole.Writer},${PersonRole.CoverArtist}`, field: PersonFilterField.Role, comparison: FilterComparison.Contains}); + this.filterSettings.presetsV2 = this.filter; this.cdRef.markForCheck(); + this.loadData(); }); - - - - this.filterGroup.valueChanges.pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(200), - tap(_ => this.loadData()) - ).subscribe() - - this.loadData(); } - onSortUpdate(isAscending: boolean) { - this.isAscending = isAscending; - this.loadData(); + + ngOnInit() { + } + loadData() { - const roles = this.filterGroup.get('roles')?.value ?? []; - const sortOptions = {sortField: parseInt(this.filterGroup.get('sortField')!.value + '', 10), isAscending: this.isAscending} as PersonSortOptions; - const query = this.filterGroup.get('query')?.value ?? ''; + console.log('loading data with filter', this.filter!); - this.personService.getAuthorsToBrowse({roles, sortOptions, query}).subscribe(d => { + if (!this.filter) { + this.filter = this.filterUtilityService.createPersonV2Filter(); + this.filter.statements.push({value: `${PersonRole.Writer},${PersonRole.CoverArtist}`, field: PersonFilterField.Role, comparison: FilterComparison.Contains}); + this.cdRef.markForCheck(); + } + + this.personService.getAuthorsToBrowse(this.filter!).subscribe(d => { this.authors = [...d.result]; this.pagination = d.pagination; this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name); @@ -138,4 +127,18 @@ export class BrowseAuthorsComponent implements OnInit { goToPerson(person: BrowsePerson) { this.router.navigate(['person', person.name]); } + + updateFilter(data: FilterEvent) { + if (data.filterV2 === undefined) return; + this.filter = data.filterV2; + + if (data.isFirst) { + this.loadData(); + return; + } + + this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => { + this.loadData(); + }); + } } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index c58b57fcd..9d084e2bf 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -1,18 +1,18 @@ - @if (header.length > 0) { + @if (header().length > 0) {

- @if (actions.length > 0) { + @if (actions().length > 0) { -   +   } - {{header}}  + {{header()}}  @if (pagination) { {{pagination.totalItems}} diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index de543a421..12ef2c7f6 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -10,10 +10,12 @@ import { HostListener, inject, Inject, + input, Input, OnChanges, OnInit, Output, + Signal, SimpleChange, SimpleChanges, TemplateRef, @@ -38,7 +40,7 @@ import {filter, map} from "rxjs/operators"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {tap} from "rxjs"; import {FilterV2} from "../../_models/metadata/v2/filter-v2"; -import {FilterSettingsBase, SeriesFilterSettings} from "../../metadata-filter/filter-settings"; +import {FilterSettingsBase, ValidFilterEntity} from "../../metadata-filter/filter-settings"; const ANIMATION_TIME_MS = 0; @@ -73,9 +75,9 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - protected readonly Breakpoint = Breakpoint; - @Input() header: string = ''; + + header: Signal = input(''); @Input() isLoading: boolean = false; @Input() items: any[] = []; @Input() pagination!: Pagination; @@ -93,17 +95,15 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { /** * Any actions to exist on the header for the parent collection (library, collection) */ - @Input() actions: ActionItem[] = []; + actions: Signal[]> = input([]); /** * A trackBy to help with rendering. This is required as without it there are issues when scrolling */ @Input({required: true}) trackByIdentity!: TrackByFunction; @Input() filterSettings!: FilterSettingsBase; + entityType = input(); @Input() refresh!: EventEmitter; - /** - * Pass the filter object optionally. If not passed, will create a SeriesFilter by default - */ - filter: FilterV2 | undefined = undefined; + /** * Will force the jumpbar to be disabled - in cases where you're not using a traditional filter config @@ -126,6 +126,11 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { updateApplied: number = 0; bufferAmount: number = 1; + /** + * Pass the filter object optionally. If not passed, will create a SeriesFilter by default + */ + filter: FilterV2 | undefined = undefined; + @@ -150,7 +155,8 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { // } if (this.filterSettings === undefined) { - this.filterSettings = new SeriesFilterSettings(); + this.filterSettings = this.filterUtilityService.getDefaultSettings(this.entityType()); + console.log('[card detail layout] Filter Setting is not set, defaulting: ', this.filterSettings); this.cdRef.markForCheck(); } @@ -231,4 +237,6 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { tryToSaveJumpKey() { this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex); } + + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index d6d75ea79..214806b5c 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -26,7 +26,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added- import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {Pagination} from 'src/app/_models/pagination'; import {Series} from 'src/app/_models/series'; -import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ActionService} from 'src/app/_services/action.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service'; @@ -48,7 +48,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; -import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterV2} from "../../../_models/metadata/v2/filter-v2"; import {AccountService} from "../../../_services/account.service"; import {User} from "../../../_models/user"; @@ -63,6 +62,8 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component"; import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {MetadataService} from "../../../_services/metadata.service"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; @Component({ selector: 'app-collection-detail', @@ -96,6 +97,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { protected readonly utilityService = inject(UtilityService); private readonly cdRef = inject(ChangeDetectorRef); private readonly scrollService = inject(ScrollService); + private readonly metadataService = inject(MetadataService); protected readonly ScrobbleProvider = ScrobbleProvider; protected readonly Breakpoint = Breakpoint; @@ -189,18 +191,26 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { } const tagId = parseInt(routeId, 10); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter as FilterV2; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + const defaultStmt = {field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); + } if (this.filter.statements.filter((stmt: FilterStatement) => stmt.field === FilterField.CollectionTags).length === 0) { - this.filter!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}); + this.filter!.statements.push(defaultStmt); } + this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; - this.cdRef.markForCheck(); this.updateTag(tagId); + this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 0d179af4b..68dcfcd34 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -17,7 +17,7 @@ import {SeriesAddedEvent} from '../_models/events/series-added-event'; import {Library} from '../_models/library/library'; import {Pagination} from '../_models/pagination'; import {Series} from '../_models/series'; -import {FilterEvent} from '../_models/metadata/series-filter'; +import {FilterEvent, SortField} from '../_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; import {ActionService} from '../_services/action.service'; import {LibraryService} from '../_services/library.service'; @@ -43,6 +43,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card- import {LoadingComponent} from "../shared/loading/loading.component"; import {debounceTime, ReplaySubject, tap} from "rxjs"; import {SeriesFilterSettings} from "../metadata-filter/filter-settings"; +import {MetadataService} from "../_services/metadata.service"; @Component({ selector: 'app-library-detail', @@ -68,6 +69,7 @@ export class LibraryDetailComponent implements OnInit { private readonly filterUtilityService = inject(FilterUtilitiesService); public readonly navService = inject(NavService); public readonly bulkSelectionService = inject(BulkSelectionService); + public readonly metadataService = inject(MetadataService); libraryId!: number; libraryName = ''; @@ -184,16 +186,19 @@ export class LibraryDetailComponent implements OnInit { this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter as FilterV2; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; - if (this.filter.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) { - this.filter!.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}); + const defaultStmt = {field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); } - this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}); + this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe(); diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts index b9f35db3b..1e2bb1a58 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.ts @@ -39,13 +39,13 @@ import {ValidFilterEntity} from "../../filter-settings"; }) export class MetadataBuilderComponent implements OnInit { - @Input({required: true}) filter!: FilterV2; + @Input({required: true}) filter!: FilterV2; /** * The number of statements that can be. 0 means unlimited. -1 means none. */ @Input() statementLimit = 0; entityType = input.required(); - @Output() update: EventEmitter> = new EventEmitter>(); + @Output() update: EventEmitter> = new EventEmitter>(); @Output() apply: EventEmitter = new EventEmitter(); private readonly cdRef = inject(ChangeDetectorRef); @@ -62,7 +62,11 @@ export class MetadataBuilderComponent(this.filter?.combination || FilterCombination.Or, [])); + this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap(values => { this.filter.combination = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterCombination; this.update.emit(this.filter); @@ -70,7 +74,8 @@ export class MetadataBuilderComponent(this.filterUtilityService.getDefaultFilterField(this.entityType())); + this.filter.statements = [statement, ...this.filter.statements]; this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html index a91796358..191a17475 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html @@ -3,8 +3,8 @@
diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 83c87c2cc..3c80a5090 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -20,17 +20,24 @@ import {MetadataService} from 'src/app/_services/metadata.service'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {FilterField} from 'src/app/_models/metadata/v2/filter-field'; import {AsyncPipe} from "@angular/common"; -import {FilterFieldPipe} from "../../../_pipes/filter-field.pipe"; import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Select2, Select2Option} from "ng-select2-component"; import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; -import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe"; -import {AgeRatingPipe} from "../../../_pipes/age-rating.pipe"; import {ValidFilterEntity} from "../../filter-settings"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; +interface FieldConfig { + type: PredicateType; + baseComparisons: FilterComparison[]; + defaultValue: any; + allowsDateComparisons?: boolean; + allowsNumberComparisons?: boolean; + excludesMustContains?: boolean; + allowsIsEmpty?: boolean; +} + enum PredicateType { Text = 1, Number = 2, @@ -56,42 +63,42 @@ const unitLabels: Map = new Map([ [FilterField.ReadLast, new FilterRowUi('unit-read-last')], ]); -const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath]; -const NumberFields = [ - FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, - FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast -]; -const DropdownFields = [ - FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, - FilterField.Translators, FilterField.Characters, FilterField.Publisher, - FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, - FilterField.Colorist, FilterField.Inker, FilterField.Penciller, - FilterField.Writers, FilterField.Genres, FilterField.Libraries, - FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, - FilterField.Imprint, FilterField.Team, FilterField.Location -]; -const BooleanFields = [FilterField.WantToRead]; -const DateFields = [FilterField.ReadingDate]; - -const DropdownFieldsWithoutMustContains = [ - FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus -]; -const DropdownFieldsThatIncludeNumberComparisons = [ - FilterField.AgeRating -]; -const NumberFieldsThatIncludeDateComparisons = [ - FilterField.ReleaseYear -]; - -const FieldsThatShouldIncludeIsEmpty = [ - FilterField.Summary, FilterField.UserRating, FilterField.Genres, - FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, - FilterField.Translators, FilterField.Characters, FilterField.Publisher, - FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, - FilterField.Colorist, FilterField.Inker, FilterField.Penciller, - FilterField.Writers, FilterField.Imprint, FilterField.Team, - FilterField.Location, -]; +// const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name]; +// const NumberFields = [ +// FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, +// FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast +// ]; +// const DropdownFields = [ +// FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, +// FilterField.Translators, FilterField.Characters, FilterField.Publisher, +// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, +// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, +// FilterField.Writers, FilterField.Genres, FilterField.Libraries, +// FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, +// FilterField.Imprint, FilterField.Team, FilterField.Location, PersonFilterField.Role +// ]; +// const BooleanFields = [FilterField.WantToRead]; +// const DateFields = [FilterField.ReadingDate]; +// +// const DropdownFieldsWithoutMustContains = [ +// FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus +// ]; +// const DropdownFieldsThatIncludeNumberComparisons = [ +// FilterField.AgeRating +// ]; +// const NumberFieldsThatIncludeDateComparisons = [ +// FilterField.ReleaseYear +// ]; +// +// const FieldsThatShouldIncludeIsEmpty = [ +// FilterField.Summary, FilterField.UserRating, FilterField.Genres, +// FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, +// FilterField.Translators, FilterField.Characters, FilterField.Publisher, +// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, +// FilterField.Colorist, FilterField.Inker, FilterField.Penciller, +// FilterField.Writers, FilterField.Imprint, FilterField.Team, +// FilterField.Location, +// ]; const StringComparisons = [ FilterComparison.Equal, @@ -128,7 +135,6 @@ const BooleanComparisons = [ imports: [ ReactiveFormsModule, AsyncPipe, - FilterFieldPipe, FilterComparisonPipe, NgbTooltip, TranslocoDirective, @@ -161,8 +167,6 @@ export class MetadataFilterRowComponent([]); loaded: boolean = false; - private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService); - private readonly ageRatingPipe = new AgeRatingPipe(); private comparisonSignal!: Signal; @@ -172,8 +176,8 @@ export class MetadataFilterRowComponent = computed(() => null); isMultiSelectDropdownAllowed: Signal = computed(() => false); - sortFieldOptions = computed(() => []); - filterFieldOptions = computed(() => []); + sortFieldOptions: Signal<{title: string, value: TFilter}[]> = computed(() => []); + filterFieldOptions: Signal<{title: string, value: TFilter}[]> = computed(() => []); ngOnInit() { @@ -213,9 +217,6 @@ export class MetadataFilterRowComponent(FilterField.SeriesName, [])); - this.formGroup.get('input')?.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe((val: string) => this.handleFieldChange(val)); this.populateFromPreset(); @@ -226,14 +227,14 @@ export class MetadataFilterRowComponent { - const inputVal = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; - return DropdownFields.includes(inputVal); + return this.filterUtilitiesService.getDropdownFields(this.entityType()).includes(this.inputSignal()); }), switchMap((_) => this.getDropdownObservable()), takeUntilDestroyed(this.destroyRef) ); + this.formGroup!.valueChanges.pipe( distinctUntilChanged(), tap(_ => this.propagateFilterUpdate()), @@ -251,7 +252,9 @@ export class MetadataFilterRowComponent(this.entityType()); + const stringFields = this.filterUtilitiesService.getStringFields(this.entityType()); + const dateFields = this.filterUtilitiesService.getDateFields(this.entityType()); + const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType()); + + if (stringFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(val); - } else if (BooleanFields.includes(this.preset.field)) { + } else if (booleanFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(val); - } else if (DateFields.includes(this.preset.field)) { + } else if (dateFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val)); } - else if (DropdownFields.includes(this.preset.field)) { + else if (dropdownFields.includes(this.preset.field)) { if (this.isMultiSelectDropdownAllowed() || val.includes(',')) { this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10))); } else { @@ -302,18 +310,29 @@ export class MetadataFilterRowComponent { - const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; - return this.metadataService.getOptionsForFilterField(filterField); + const filterField = this.inputSignal(); + return this.metadataService.getOptionsForFilterField(filterField, this.entityType()); } handleFieldChange(val: string) { - const inputVal = parseInt(val, 10) as FilterField; + const inputVal = parseInt(val, 10) as TFilter; + console.log('input', inputVal);inputVal + const stringFields = this.filterUtilitiesService.getStringFields(this.entityType()); + const dropdownFields = this.filterUtilitiesService.getDropdownFields(this.entityType()); + const numberFields = this.filterUtilitiesService.getNumberFields(this.entityType()); + const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType()); + const dateFields = this.filterUtilitiesService.getDateFields(this.entityType()); + const fieldsThatShouldIncludeIsEmpty = this.filterUtilitiesService.getFieldsThatShouldIncludeIsEmpty(this.entityType()); + const numberFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getNumberFieldsThatIncludeDateComparisons(this.entityType()); + const dropdownFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeDateComparisons(this.entityType()); + const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains(this.entityType()); + const dropdownFieldsThatIncludeNumberComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeNumberComparisons(this.entityType()); - if (StringFields.includes(inputVal)) { + if (stringFields.includes(inputVal)) { let comps = [...StringComparisons]; - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } @@ -328,13 +347,13 @@ export class MetadataFilterRowComponent c !== FilterComparison.MustContains); } - if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) { + if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) { comps.push(FilterComparison.IsEmpty); } diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index c9a253d26..092ec4740 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -18,6 +18,7 @@ export class FilterSettingsBase { type: ValidFilterEntity = 'series'; + supportsSmartFilter = true; } /** diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 6e5189728..c50834433 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -43,15 +43,19 @@
-
- - -
+ + @if (filterSettings().supportsSmartFilter) { +
+ + +
+ } + @if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) { diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index d6e362425..2913276c9 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -11,22 +11,21 @@ import { input, Input, OnInit, - Output + Output, + Signal } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; import {Breakpoint, UtilityService} from '../shared/_services/utility.service'; import {Library} from '../_models/library/library'; -import {allSeriesSortFields, FilterEvent, FilterItem} from '../_models/metadata/series-filter'; +import {FilterEvent, FilterItem} from '../_models/metadata/series-filter'; import {ToggleService} from '../_services/toggle.service'; import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {DrawerComponent} from '../shared/drawer/drawer.component'; -import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common'; +import {AsyncPipe, JsonPipe, NgClass, NgTemplateOutlet} from '@angular/common'; import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco"; -import {SortFieldPipe} from "../_pipes/sort-field.pipe"; import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component"; -import {FilterField} from "../_models/metadata/v2/filter-field"; import {FilterService} from "../_services/filter.service"; import {ToastrService} from "ngx-toastr"; import {animate, style, transition, trigger} from "@angular/animations"; @@ -54,7 +53,7 @@ import {FilterUtilitiesService} from "../shared/_services/filter-utilities.servi changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgTemplateOutlet, DrawerComponent, ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, - MetadataBuilderComponent, NgClass, SortButtonComponent] + MetadataBuilderComponent, NgClass, SortButtonComponent, JsonPipe] }) export class MetadataFilterComponent implements OnInit { @@ -67,12 +66,6 @@ export class MetadataFilterComponent { - return {title: this.sortFieldPipe.transform(f), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)); - /** * This toggles the opening/collapsing of the metadata filter code @@ -97,22 +90,22 @@ export class MetadataFilterComponent | undefined; - sortFieldOptions = computed(() => { - return this.filterUtilitiesService.getSortFields(this.filterSettings().type); - }); - filterFieldOptions = computed(() => { - return this.filterUtilitiesService.getFilterFields(this.filterSettings().type); - }); - + filterV2: FilterV2 | undefined; + sortFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); + filterFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); constructor() { effect(() => { - console.log('setting type: ', this.filterSettings().type); + const settings = this.filterSettings(); + if (settings?.presetsV2) { + this.filterV2 = this.deepClone(settings.presetsV2); + this.cdRef.markForCheck(); + } }) } + ngOnInit(): void { if (this.filterOpen) { this.filterOpen.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(openState => { @@ -123,6 +116,15 @@ export class MetadataFilterComponent { + return this.filterUtilitiesService.getFilterFields(this.filterSettings().type); + }); + + this.sortFieldOptions = computed(() => { + return this.filterUtilitiesService.getSortFields(this.filterSettings().type); + }); + + this.loadFromPresetsAndSetup(); } @@ -159,7 +161,8 @@ export class MetadataFilterComponent) { + handleFilters(filter: FilterV2) { + console.log('handleFilters', filter); this.filterV2 = filter; } @@ -167,31 +170,37 @@ export class MetadataFilterComponent { - if (this.filterV2?.sortOptions === null) { - this.filterV2.sortOptions = { - isAscending: this.isAscendingSort, - sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) - }; - } - this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); - this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); - this.filterV2!.name = this.sortGroup.get('name')?.value || ''; - this.cdRef.markForCheck(); + if (this.filterV2?.sortOptions === null) { + this.filterV2.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort + }; + } + this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort; + this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); + this.filterV2!.name = this.sortGroup.get('name')?.value || ''; + this.cdRef.markForCheck(); }); this.fullyLoaded = true; @@ -204,7 +213,7 @@ export class MetadataFilterComponent>(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => { if (filter == null) { - filter = this.metadataService.createDefaultFilterDto(); + filter = this.metadataService.createDefaultFilterDto('series'); filter.statements.push(this.createSeriesV2DefaultStatement()); } @@ -49,9 +61,9 @@ export class FilterUtilitiesService { })); } - filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot): Observable> { - const filter = this.metadataService.createDefaultFilterDto(); - filter.statements.push(this.createSeriesV2DefaultStatement()); + filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot, entityType: ValidFilterEntity, defaultStatement: FilterStatement | null = null): Observable> { + const filter = this.metadataService.createDefaultFilterDto(entityType); + filter.statements.push(defaultStatement ?? this.createSeriesV2DefaultStatement()); if (!window.location.href.includes('?')) return of(filter); return this.decodeFilter(window.location.href.split('?')[1]); @@ -66,7 +78,7 @@ export class FilterUtilitiesService { */ applyFilter(page: Array, filter: FilterField, comparison: FilterComparison, value: string) { const dto = this.createSeriesV2Filter(); - dto.statements.push(this.metadataService.createDefaultFilterStatement(filter, comparison, value + '')); + dto.statements.push(this.metadataService.createFilterStatement(filter, comparison, value + '')); return this.encodeFilter(dto).pipe(switchMap(encodedFilter => { return this.router.navigateByUrl(page.join('/') + '?' + encodedFilter); @@ -125,22 +137,30 @@ export class FilterUtilitiesService { getSortFields(type: ValidFilterEntity) { switch (type) { case 'series': - return allSeriesSortFields as unknown as T[]; + return allSeriesSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; case 'person': - return allPersonSortFields as unknown as T[]; + return allPersonSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; default: - return [] as T[]; + return [] as {title: string, value: T}[]; } } - getFilterFields(type: ValidFilterEntity) { + getFilterFields(type: ValidFilterEntity): {title: string, value: T}[] { switch (type) { case 'series': - return allSeriesFilterFields as unknown as T[]; + return allSeriesFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; case 'person': - return allPersonFilterFields as unknown as T[]; + return allPersonFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; default: - return [] as T[]; + return [] as {title: string, value: T}[]; } } @@ -152,4 +172,161 @@ export class FilterUtilitiesService { return PersonFilterField.Role as unknown as T; } } + + /** + * Returns the appropriate Dropdown Fields based on the entity type + * @param type + */ + getDropdownFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Genres, FilterField.Libraries, + FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, + FilterField.Imprint, FilterField.Team, FilterField.Location + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Role + ] as unknown as T[]; + } + } + + getStringFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Name + ] as unknown as T[]; + } + } + + getNumberFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, + FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.ChapterCount, PersonFilterField.SeriesCount + ] as unknown as T[]; + } + } + + getBooleanFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.WantToRead + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDateFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadingDate + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getNumberFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReleaseYear + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsWithoutMustContains(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeNumberComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getFieldsThatShouldIncludeIsEmpty(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Summary, FilterField.UserRating, FilterField.Genres, + FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Imprint, FilterField.Team, + FilterField.Location + ] as unknown as T[]; + case 'person': + return [] as unknown as T[]; + } + } + + getDefaultSettings(entityType: ValidFilterEntity | "other" | undefined): FilterSettingsBase { + if (entityType === 'other' || entityType === undefined) { + // It doesn't matter, return series type + return new SeriesFilterSettings(); + } + + if (entityType == 'series') return new SeriesFilterSettings(); + if (entityType == 'person') return new PersonFilterSettings(); + + return new SeriesFilterSettings(); + } } diff --git a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts index 7eafcea63..3ffbd7a7d 100644 --- a/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts +++ b/UI/Web/src/app/want-to-read/_components/want-to-read/want-to-read.component.ts @@ -22,7 +22,7 @@ import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {Pagination} from 'src/app/_models/pagination'; import {Series} from 'src/app/_models/series'; -import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; import {Action, ActionItem} from 'src/app/_services/action-factory.service'; import {ActionService} from 'src/app/_services/action.service'; import {ImageService} from 'src/app/_services/image.service'; @@ -41,6 +41,9 @@ import {translate, TranslocoDirective} from "@jsverse/transloco"; import {FilterV2} from "../../../_models/metadata/v2/filter-v2"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {MetadataService} from "../../../_services/metadata.service"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; @Component({ @@ -55,6 +58,7 @@ export class WantToReadComponent implements OnInit, AfterContentChecked { @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; private readonly destroyRef = inject(DestroyRef); + private readonly metadataService = inject(MetadataService); isLoading: boolean = true; series: Array = []; @@ -108,13 +112,23 @@ export class WantToReadComponent implements OnInit, AfterContentChecked { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.titleService.setTitle('Kavita - ' + translate('want-to-read.title')); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + const defaultStmt = {field: FilterField.WantToRead, value: 'true', comparison: FilterComparison.Equal} as FilterStatement; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); + } + this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; + this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 56d7eb0da..ba70b0f8e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2085,7 +2085,10 @@ "release-year": "Release Year", "read-progress": "Last Read", "average-rating": "Average Rating", - "random": "Random" + "random": "Random", + "person-name": "Name", + "person-series-count": "Series Count", + "person-chapter-count": "Chapter Count" }, "edit-series-modal": { @@ -2608,6 +2611,13 @@ "location": "In {{value}} location" }, + "generic-filter-field-pipe": { + "person-role": "Role", + "person-name": "Name", + "person-series-count": "Series Count", + "person-chapter-count": "Chapter Count" + }, + "toasts": { "regen-cover": "A job has been enqueued to regenerate the cover image",