Refactored Browse Authors to be Browse People and allow the user to select the role to show.

After some experimentation, I think I need to implement the proper filtering system as there are 3 potential filters.

Added browse genres/tags on the top right nav bar temp while I figure out a better implementation for the idea.
This commit is contained in:
Joseph Milazzo 2025-06-07 11:33:50 -05:00
parent 7ef95b3e12
commit a7d4b11593
45 changed files with 326 additions and 98 deletions

View file

@ -9,6 +9,7 @@ using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Metadata.Browse;
using API.DTOs.Person; using API.DTOs.Person;
using API.DTOs.Recommendation; using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;

View file

@ -5,6 +5,8 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Person; using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
@ -78,11 +80,11 @@ public class PersonController : BaseApiController
/// <param name="userParams"></param> /// <param name="userParams"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("all")] [HttpPost("all")]
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams)
{ {
userParams ??= UserParams.Default; userParams ??= UserParams.Default;
var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), [PersonRole.CoverArtist, PersonRole.Writer], userParams); var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list); return Ok(list);

View file

@ -0,0 +1,8 @@
namespace API.DTOs.Filtering;
public enum PersonSortField
{
Name = 1,
SeriesCount = 2,
ChapterCount = 3
}

View file

@ -8,3 +8,12 @@ public sealed record SortOptions
public SortField SortField { get; set; } public SortField SortField { get; set; }
public bool IsAscending { get; set; } = true; public bool IsAscending { get; set; } = true;
} }
/// <summary>
/// All Sorting Options for a query related to Person Entity
/// </summary>
public sealed record PersonSortOptions
{
public PersonSortField SortField { get; set; }
public bool IsAscending { get; set; } = true;
}

View file

@ -1,4 +1,4 @@
namespace API.DTOs.Metadata; namespace API.DTOs.Metadata.Browse;
public sealed record BrowseGenreDto : GenreTagDto public sealed record BrowseGenreDto : GenreTagDto
{ {
@ -7,7 +7,7 @@ public sealed record BrowseGenreDto : GenreTagDto
/// </summary> /// </summary>
public int SeriesCount { get; set; } public int SeriesCount { get; set; }
/// <summary> /// <summary>
/// Number of Issues this Entity is on /// Number of Chapters this Entity is on
/// </summary> /// </summary>
public int IssueCount { get; set; } public int ChapterCount { get; set; }
} }

View file

@ -1,6 +1,6 @@
using API.DTOs.Person; using API.DTOs.Person;
namespace API.DTOs; namespace API.DTOs.Metadata.Browse;
/// <summary> /// <summary>
/// Used to browse writers and click in to see their series /// Used to browse writers and click in to see their series
@ -14,5 +14,5 @@ public class BrowsePersonDto : PersonDto
/// <summary> /// <summary>
/// Number of Issues this Person is the Writer for /// Number of Issues this Person is the Writer for
/// </summary> /// </summary>
public int IssueCount { get; set; } public int ChapterCount { get; set; }
} }

View file

@ -1,6 +1,4 @@
using API.Entities; namespace API.DTOs.Metadata.Browse;
namespace API.DTOs.Metadata;
public sealed record BrowseTagDto : TagDto public sealed record BrowseTagDto : TagDto
{ {
@ -9,7 +7,7 @@ public sealed record BrowseTagDto : TagDto
/// </summary> /// </summary>
public int SeriesCount { get; set; } public int SeriesCount { get; set; }
/// <summary> /// <summary>
/// Number of Issues this Entity is on /// Number of Chapters this Entity is on
/// </summary> /// </summary>
public int IssueCount { get; set; } public int ChapterCount { get; set; }
} }

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using API.DTOs.Filtering;
using API.Entities.Enums;
namespace API.DTOs.Metadata.Browse.Requests;
#nullable enable
public sealed record BrowsePersonFilterDto
{
public required List<PersonRole> Roles { get; set; }
public string? Query { get; set; }
public PersonSortOptions? SortOptions { get; set; }
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Metadata.Browse;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
@ -182,7 +183,7 @@ public class GenreRepository : IGenreRepository
.Select(sm => sm.Id) .Select(sm => sm.Id)
.Distinct() .Distinct()
.Count(), .Count(),
IssueCount = g.Chapters ChapterCount = g.Chapters
.Select(ch => ch.Id) .Select(ch => ch.Id)
.Distinct() .Distinct()
.Count() .Count()

View file

@ -4,6 +4,8 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering.v2; using API.DTOs.Filtering.v2;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Person; using API.DTOs.Person;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Person; using API.Entities.Person;
@ -46,7 +48,7 @@ public interface IPersonRepository
Task<string?> GetCoverImageAsync(int personId); Task<string?> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageByNameAsync(string name); Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId); Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, List<PersonRole> roles, UserParams userParams); Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams);
Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); Task<Person?> GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None);
Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); Task<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases);
/// <summary> /// <summary>
@ -195,13 +197,15 @@ public class PersonRepository : IPersonRepository
return chapterRoles.Union(seriesRoles).Distinct(); return chapterRoles.Union(seriesRoles).Distinct();
} }
public async Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, List<PersonRole> roles, UserParams userParams) public async Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams)
{ {
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
var query = _context.Person var query = _context.Person
.Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) .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) .RestrictAgainstAgeRestriction(ageRating)
.SortBy(filter.SortOptions)
.Select(p => new BrowsePersonDto .Select(p => new BrowsePersonDto
{ {
Id = p.Id, Id = p.Id,
@ -209,17 +213,17 @@ public class PersonRepository : IPersonRepository
Description = p.Description, Description = p.Description,
CoverImage = p.CoverImage, CoverImage = p.CoverImage,
SeriesCount = p.SeriesMetadataPeople SeriesCount = p.SeriesMetadataPeople
.Where(smp => roles.Contains(smp.Role)) .Where(smp => filter.Roles.Contains(smp.Role))
.Select(smp => smp.SeriesMetadata.SeriesId) .Select(smp => smp.SeriesMetadata.SeriesId)
.Distinct() .Distinct()
.Count(), .Count(),
IssueCount = p.ChapterPeople ChapterCount = p.ChapterPeople
.Where(cp => roles.Contains(cp.Role)) .Where(cp => filter.Roles.Contains(cp.Role))
.Select(cp => cp.Chapter.Id) .Select(cp => cp.Chapter.Id)
.Distinct() .Distinct()
.Count() .Count()
}) })
.OrderBy(p => p.Name); ;
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
} }

View file

@ -2,6 +2,7 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Metadata.Browse;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
@ -120,7 +121,7 @@ public class TagRepository : ITagRepository
.Select(sm => sm.Id) .Select(sm => sm.Id)
.Distinct() .Distinct()
.Count(), .Count(),
IssueCount = g.Chapters ChapterCount = g.Chapters
.Select(ch => ch.Id) .Select(ch => ch.Id)
.Distinct() .Distinct()
.Count() .Count()

View file

@ -5,10 +5,13 @@ using System.Linq.Expressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.Misc; using API.Data.Misc;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.DTOs.KavitaPlus.Manage; using API.DTOs.KavitaPlus.Manage;
using API.DTOs.Metadata.Browse;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Person;
using API.Entities.Scrobble; using API.Entities.Scrobble;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -273,6 +276,27 @@ public static class QueryableExtensions
}; };
} }
public static IQueryable<Person> SortBy(this IQueryable<Person> query, PersonSortOptions? sort)
{
if (sort == null)
{
return query.OrderBy(p => p.Name);
}
return sort.SortField switch
{
PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name),
PersonSortField.Name => query.OrderByDescending(p => p.Name),
PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count),
PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count),
PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count),
PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count),
_ => query.OrderBy(p => p.Name)
};
}
/// <summary> /// <summary>
/// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending. /// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending.
/// </summary> /// </summary>

View file

@ -2,5 +2,5 @@ import {Genre} from "../genre";
export interface BrowseGenre extends Genre { export interface BrowseGenre extends Genre {
seriesCount: number; seriesCount: number;
issueCount: number; chapterCount: number;
} }

View file

@ -1,6 +1,6 @@
import {Person} from "../metadata/person"; import {Person} from "../person";
export interface BrowsePerson extends Person { export interface BrowsePerson extends Person {
seriesCount: number; seriesCount: number;
issueCount: number; chapterCount: number;
} }

View file

@ -2,5 +2,5 @@ import {Tag} from "../../tag";
export interface BrowseTag extends Tag { export interface BrowseTag extends Tag {
seriesCount: number; seriesCount: number;
issueCount: number; chapterCount: number;
} }

View file

@ -4,7 +4,10 @@ export interface Language {
} }
export interface KavitaLocale { export interface KavitaLocale {
fileName: string; // isoCode aka what maps to the file on disk and what transloco loads /**
* isoCode aka what maps to the file on disk and what transloco loads
*/
fileName: string;
renderName: string; renderName: string;
translationCompletion: number; translationCompletion: number;
isRtL: boolean; isRtL: boolean;

View file

@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover";
export enum PersonRole { export enum PersonRole {
Other = 1, Other = 1,
Artist = 2,
Writer = 3, Writer = 3,
Penciller = 4, Penciller = 4,
Inker = 5, Inker = 5,
@ -32,3 +31,22 @@ export interface Person extends IHasCover {
primaryColor: string; primaryColor: string;
secondaryColor: string; secondaryColor: string;
} }
/**
* Excludes Other as it's not in use
*/
export const allPeopleRoles = [
PersonRole.Writer,
PersonRole.Penciller,
PersonRole.Inker,
PersonRole.Colorist,
PersonRole.Letterer,
PersonRole.CoverArtist,
PersonRole.Editor,
PersonRole.Publisher,
PersonRole.Character,
PersonRole.Translator,
PersonRole.Imprint,
PersonRole.Team,
PersonRole.Location
]

View file

@ -7,10 +7,6 @@ export interface FilterItem<T> {
selected: boolean; selected: boolean;
} }
export interface SortOptions {
sortField: SortField;
isAscending: boolean;
}
export enum SortField { export enum SortField {
SortName = 1, SortName = 1,

View file

@ -0,0 +1,8 @@
import {PersonRole} from "../person";
import {PersonSortOptions} from "./sort-options";
export interface BrowsePersonFilter {
roles: Array<PersonRole>;
query?: string;
sortOptions?: PersonSortOptions;
}

View file

@ -66,7 +66,6 @@ export const allPeople = [
export const personRoleForFilterField = (role: PersonRole) => { export const personRoleForFilterField = (role: PersonRole) => {
switch (role) { switch (role) {
case PersonRole.Artist: return FilterField.CoverArtist;
case PersonRole.Character: return FilterField.Characters; case PersonRole.Character: return FilterField.Characters;
case PersonRole.Colorist: return FilterField.Colorist; case PersonRole.Colorist: return FilterField.Colorist;
case PersonRole.CoverArtist: return FilterField.CoverArtist; case PersonRole.CoverArtist: return FilterField.CoverArtist;

View file

@ -0,0 +1,5 @@
export enum PersonSortField {
Name = 1,
SeriesCount = 2,
ChapterCount = 3
}

View file

@ -1,6 +1,6 @@
import { SortOptions } from "../series-filter";
import {FilterStatement} from "./filter-statement"; import {FilterStatement} from "./filter-statement";
import {FilterCombination} from "./filter-combination"; import {FilterCombination} from "./filter-combination";
import {SortOptions} from "./sort-options";
export interface SeriesFilterV2 { export interface SeriesFilterV2 {
name?: string; name?: string;

View file

@ -0,0 +1,18 @@
import {SortField} from "../series-filter";
import {PersonSortField} from "./person-sort-field";
/**
* Series-based Sort options
*/
export interface SortOptions {
sortField: SortField;
isAscending: boolean;
}
/**
* Person-based Sort Options
*/
export interface PersonSortOptions {
sortField: PersonSortField;
isAscending: boolean;
}

View file

@ -1,6 +1,6 @@
import {inject, Pipe, PipeTransform} from '@angular/core'; import {Pipe, PipeTransform} from '@angular/core';
import {PersonRole} from '../_models/metadata/person'; import {PersonRole} from '../_models/metadata/person';
import {translate, TranslocoService} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
@Pipe({ @Pipe({
name: 'personRole', name: 'personRole',
@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform {
transform(value: PersonRole): string { transform(value: PersonRole): string {
switch (value) { switch (value) {
case PersonRole.Artist:
return translate('person-role-pipe.artist');
case PersonRole.Character: case PersonRole.Character:
return translate('person-role-pipe.character'); return translate('person-role-pipe.character');
case PersonRole.Colorist: case PersonRole.Colorist:

View file

@ -178,8 +178,6 @@ export class MetadataService {
switch (role) { switch (role) {
case PersonRole.Other: case PersonRole.Other:
break; break;
case PersonRole.Artist:
break;
case PersonRole.CoverArtist: case PersonRole.CoverArtist:
entity.coverArtists = persons; entity.coverArtists = persons;
break; break;

View file

@ -6,9 +6,10 @@ import {PaginatedResult} from "../_models/pagination";
import {Series} from "../_models/series"; import {Series} from "../_models/series";
import {map} from "rxjs/operators"; import {map} from "rxjs/operators";
import {UtilityService} from "../shared/_services/utility.service"; import {UtilityService} from "../shared/_services/utility.service";
import {BrowsePerson} from "../_models/person/browse-person"; import {BrowsePerson} from "../_models/metadata/browse/browse-person";
import {StandaloneChapter} from "../_models/standalone-chapter"; import {StandaloneChapter} from "../_models/standalone-chapter";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {BrowsePersonFilter} from "../_models/metadata/v2/browse-person-filter";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -43,11 +44,11 @@ export class PersonService {
return this.httpClient.get<Array<StandaloneChapter>>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); return this.httpClient.get<Array<StandaloneChapter>>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`);
} }
getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams(); let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe(
map((response: any) => { map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>; return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
}) })

View file

@ -0,0 +1,9 @@
<ng-container *transloco="let t">
<button class="btn btn-sm btn-icon" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="isDisabled()">
@if (currentAscending()) {
<i class="fa fa-arrow-up" [title]="t('metadata-filter.ascending-alt')"></i>
} @else {
<i class="fa fa-arrow-down" [title]="t('metadata-filter.descending-alt')"></i>
}
</button>
</ng-container>

View file

@ -0,0 +1,32 @@
import {ChangeDetectionStrategy, Component, computed, EventEmitter, input, Output, signal} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-sort-button',
imports: [
TranslocoDirective
],
templateUrl: './sort-button.component.html',
styleUrl: './sort-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SortButtonComponent {
// input is replacement for @Input
disabled = input<boolean>(false);
isAscending = input<boolean>(true);
// signal is for internal state, whenever component needs to update the state internally. Not needed for disabled since component doesn't internally modify
private isAscendingSignal = signal(this.isAscending());
// Computed signals for template
protected currentAscending = computed(() => this.isAscendingSignal());
protected isDisabled = computed(() => this.disabled());
@Output() update = new EventEmitter<boolean>();
updateSortOrder() {
this.isAscendingSignal.set(!this.isAscendingSignal());
this.update.emit(this.isAscendingSignal());
}
}

View file

@ -23,7 +23,7 @@
<div class="tag-name">{{ item.title }}</div> <div class="tag-name">{{ item.title }}</div>
<div class="tag-meta"> <div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span> <span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
<span>{{t('issue-count', {num: item.issueCount | compactNumber})}}</span> <span>{{t('issue-count', {num: item.chapterCount | compactNumber})}}</span>
</div> </div>
</div> </div>

View file

@ -6,7 +6,7 @@ import {
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {JumpbarService} from "../../_services/jumpbar.service"; import {JumpbarService} from "../../_services/jumpbar.service";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {Pagination} from "../../_models/pagination"; import {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key"; import {JumpKey} from "../../_models/jumpbar/jump-key";
import {MetadataService} from "../../_services/metadata.service"; import {MetadataService} from "../../_services/metadata.service";

View file

@ -8,6 +8,44 @@
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
<form [formGroup]="filterGroup">
<div class="row mb-3">
<div class="col-md-4 col-auto">
<div class="form-group">
<label for="role-selection">{{t('roles-label')}}</label>
<select2 [data]="allRoles"
id="role-selection"
formControlName="roles"
[hideSelectedItems]="true"
[multiple]="true"
[infiniteScroll]="true"
[resettable]="true"
style="min-width: 200px">
</select2>
</div>
</div>
<div class="col-auto ms-auto me-auto">
<div class="form-group">
<label for="query">Filter</label>
<input type="text" id="query" style="min-width: 100px" autocomplete="off" formControlName="query" class="form-control" />
</div>
</div>
<div class="col-auto ms-auto">
<div class="form-group">
<label for="sort">{{t('sort-label')}}</label>
<app-sort-button (update)="onSortUpdate($event)"></app-sort-button>
<select class="form-select" style="min-width: 100px" formControlName="sortField">
<option [value]="PersonSortField.Name">{{t('name-label')}}</option>
<option [value]="PersonSortField.SeriesCount">{{t('series-count-label')}}</option>
<option [value]="PersonSortField.ChapterCount">{{t('issue-count-label')}}</option>
</select>
</div>
</div>
</div>
</form>
<app-card-detail-layout <app-card-detail-layout
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="authors" [items]="authors"
@ -15,14 +53,15 @@
[trackByIdentity]="trackByIdentity" [trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys" [jumpBarKeys]="jumpKeys"
[filteringDisabled]="true" [filteringDisabled]="true"
[customSort]="filterGroup.get('sortField')!.value !== PersonSortField.Name"
[refresh]="refresh" [refresh]="refresh"
> >
<ng-template #cardItem let-item let-position="idx"> <ng-template #cardItem let-item let-position="idx">
<app-person-card [entity]="item" [title]="item.name" [imageUrl]="imageService.getPersonImage(item.id)" (clicked)="goToPerson(item)"> <app-person-card [entity]="item" [title]="item.name" [imageUrl]="imageService.getPersonImage(item.id)" (clicked)="goToPerson(item)">
<ng-template #subtitle> <ng-template #subtitle>
<div class="d-flex justify-content-evenly"> <div class="tag-meta">
<div style="font-size: 12px">{{t('series-count', {num: item.seriesCount | compactNumber})}}</div> <div style="font-size: 12px">{{t('series-count', {num: item.seriesCount | compactNumber})}}</div>
<div style="font-size: 12px">{{t('issue-count', {num: item.issueCount | compactNumber})}}</div> <div style="font-size: 12px">{{t('issue-count', {num: item.chapterCount | compactNumber})}}</div>
</div> </div>
</ng-template> </ng-template>
</app-person-card> </app-person-card>

View file

@ -1,3 +1,5 @@
@use '../../../tag-card-common';
.main-container { .main-container {
margin-top: 10px; margin-top: 10px;
padding: 0 0 0 10px; padding: 0 0 0 10px;

View file

@ -17,12 +17,21 @@ import {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key"; import {JumpKey} from "../../_models/jumpbar/jump-key";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {PersonService} from "../../_services/person.service"; import {PersonService} from "../../_services/person.service";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {JumpbarService} from "../../_services/jumpbar.service"; import {JumpbarService} from "../../_services/jumpbar.service";
import {PersonCardComponent} from "../../cards/person-card/person-card.component"; import {PersonCardComponent} from "../../cards/person-card/person-card.component";
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; 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 {PersonSortField} from "../../_models/metadata/v2/person-sort-field";
import {PersonSortOptions} from "../../_models/metadata/v2/sort-options";
@Component({ @Component({
@ -34,15 +43,19 @@ import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
DecimalPipe, DecimalPipe,
PersonCardComponent, PersonCardComponent,
CompactNumberPipe, CompactNumberPipe,
Select2,
ReactiveFormsModule,
SortButtonComponent,
], ],
templateUrl: './browse-authors.component.html', templateUrl: './browse-authors.component.html',
styleUrl: './browse-authors.component.scss', styleUrl: './browse-authors.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class BrowseAuthorsComponent implements OnInit { export class BrowseAuthorsComponent implements OnInit {
protected readonly PersonSortField = PersonSortField;
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly personService = inject(PersonService); private readonly personService = inject(PersonService);
private readonly jumpbarService = inject(JumpbarService); private readonly jumpbarService = inject(JumpbarService);
@ -56,12 +69,41 @@ export class BrowseAuthorsComponent implements OnInit {
refresh: EventEmitter<void> = new EventEmitter(); refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = []; jumpKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; 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;
ngOnInit() { ngOnInit() {
this.isLoading = true; this.isLoading = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => {
this.authors = d.result; this.filterGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef),
debounceTime(200),
tap(_ => this.loadData())
).subscribe()
this.loadData();
}
onSortUpdate(isAscending: boolean) {
this.isAscending = isAscending;
this.loadData();
}
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 ?? '';
this.personService.getAuthorsToBrowse({roles, sortOptions, query}).subscribe(d => {
this.authors = [...d.result];
this.pagination = d.pagination; this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name); this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
this.isLoading = false; this.isLoading = false;
@ -72,5 +114,4 @@ export class BrowseAuthorsComponent implements OnInit {
goToPerson(person: BrowsePerson) { goToPerson(person: BrowsePerson) {
this.router.navigate(['person', person.name]); this.router.navigate(['person', person.name]);
} }
} }

View file

@ -23,7 +23,7 @@
<div class="tag-name">{{ item.title }}</div> <div class="tag-name">{{ item.title }}</div>
<div class="tag-meta"> <div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span> <span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
<span>{{t('issue-count', {num: item.issueCount | compactNumber})}}</span> <span>{{t('issue-count', {num: item.chapterCount | compactNumber})}}</span>
</div> </div>
</div> </div>

View file

@ -11,7 +11,7 @@ import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.se
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre"; import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
import {Pagination} from "../../_models/pagination"; import {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key"; import {JumpKey} from "../../_models/jumpbar/jump-key";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {FilterField} from "../../_models/metadata/v2/filter-field"; import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {BrowseTag} from "../../_models/metadata/browse/browse-tag"; import {BrowseTag} from "../../_models/metadata/browse/browse-tag";

View file

@ -88,7 +88,10 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
@Input() filterSettings!: FilterSettings; @Input() filterSettings!: FilterSettings;
@Input() refresh!: EventEmitter<void>; @Input() refresh!: EventEmitter<void>;
/**
* Will force the jumpbar to be disabled - in cases where you're not using a traditional filter config
*/
@Input() customSort: boolean = false;
@Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys @Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
@ -109,6 +112,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
constructor(@Inject(DOCUMENT) private document: Document) {} constructor(@Inject(DOCUMENT) private document: Document) {}
@ -171,11 +175,10 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
} }
hasCustomSort() { hasCustomSort() {
if (this.customSort) return true;
if (this.filteringDisabled) return false; if (this.filteringDisabled) return false;
const hasCustomSort = this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending;
//const hasNonDefaultSortField = this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName;
return hasCustomSort; return this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending;
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {

View file

@ -46,7 +46,7 @@ import {NextExpectedChapter} from "../../_models/series-detail/next-expected-cha
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson; export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson;

View file

@ -19,7 +19,7 @@ import {ScrollService} from "../../_services/scroll.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {NgTemplateOutlet} from "@angular/common"; import {NgTemplateOutlet} from "@angular/common";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {Person} from "../../_models/metadata/person"; import {Person} from "../../_models/metadata/person";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";

View file

@ -41,13 +41,7 @@
</div> </div>
<div class="col-md-3 col-sm-9"> <div class="col-md-3 col-sm-9">
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label> <label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
<button class="btn btn-sm btn-icon" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled"> <app-sort-button [disabled]="filterSettings.sortDisabled" (update)="updateSortOrder($event)" [isAscending]="isAscendingSort" />
@if (isAscendingSort) {
<i class="fa fa-arrow-up" [title]="t('ascending-alt')"></i>
} @else {
<i class="fa fa-arrow-down" [title]="t('descending-alt')"></i>
}
</button>
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;"> <select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
@for(field of allSortFields; track field.value) { @for(field of allSortFields; track field.value) {
<option [value]="field.value">{{field.title}}</option> <option [value]="field.value">{{field.title}}</option>

View file

@ -28,6 +28,7 @@ import {allFields} from "../_models/metadata/v2/filter-field";
import {FilterService} from "../_services/filter.service"; import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {animate, style, transition, trigger} from "@angular/animations"; import {animate, style, transition, trigger} from "@angular/animations";
import {SortButtonComponent} from "../_single-module/sort-button/sort-button.component";
@Component({ @Component({
selector: 'app-metadata-filter', selector: 'app-metadata-filter',
@ -48,7 +49,7 @@ import {animate, style, transition, trigger} from "@angular/animations";
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet, DrawerComponent, imports: [NgTemplateOutlet, DrawerComponent,
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
MetadataBuilderComponent, NgClass] MetadataBuilderComponent, NgClass, SortButtonComponent]
}) })
export class MetadataFilterComponent implements OnInit { export class MetadataFilterComponent implements OnInit {
@ -185,9 +186,10 @@ export class MetadataFilterComponent implements OnInit {
} }
updateSortOrder() { updateSortOrder(isAscending: boolean) {
if (this.filterSettings.sortDisabled) return; if (this.filterSettings.sortDisabled) return;
this.isAscendingSort = !this.isAscendingSort; this.isAscendingSort = isAscending;
if (this.filterV2?.sortOptions === null) { if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = { this.filterV2.sortOptions = {
isAscending: this.isAscendingSort, isAscending: this.isAscendingSort,

View file

@ -206,6 +206,8 @@
</button> </button>
<div ngbDropdownMenu> <div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a> <a ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a>
<a ngbDropdownItem routerLink="/browse/genres">{{t('browse-genres')}}</a>
<a ngbDropdownItem routerLink="/browse/tags">{{t('browse-tags')}}</a>
<a ngbDropdownItem routerLink="/announcements/">{{t('announcements')}}</a> <a ngbDropdownItem routerLink="/announcements/">{{t('announcements')}}</a>
<a ngbDropdownItem [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a> <a ngbDropdownItem [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a> <a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>

View file

@ -5,15 +5,7 @@ import { environment } from 'src/environments/environment';
import {ConfirmService} from '../confirm.service'; import {ConfirmService} from '../confirm.service';
import {Chapter} from 'src/app/_models/chapter'; import {Chapter} from 'src/app/_models/chapter';
import {Volume} from 'src/app/_models/volume'; import {Volume} from 'src/app/_models/volume';
import { import {asyncScheduler, BehaviorSubject, filter, finalize, Observable, of, tap,} from 'rxjs';
asyncScheduler,
BehaviorSubject,
Observable,
tap,
finalize,
of,
filter,
} from 'rxjs';
import {download, Download} from '../_models/download'; import {download, Download} from '../_models/download';
import {PageBookmark} from 'src/app/_models/readers/page-bookmark'; import {PageBookmark} from 'src/app/_models/readers/page-bookmark';
import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators'; import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators';
@ -26,7 +18,7 @@ import {UtilityService} from "./utility.service";
import {UserCollection} from "../../_models/collection-tag"; import {UserCollection} from "../../_models/collection-tag";
import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {RecentlyAddedItem} from "../../_models/recently-added-item";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {BrowsePerson} from "../../_models/person/browse-person"; import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
export const DEBOUNCE_TIME = 100; export const DEBOUNCE_TIME = 100;

View file

@ -1,6 +1,6 @@
import {inject, Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, Params, Router} from '@angular/router'; import {ActivatedRouteSnapshot, Params, Router} from '@angular/router';
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter'; import {SortField} from 'src/app/_models/metadata/series-filter';
import {MetadataService} from "../../_services/metadata.service"; import {MetadataService} from "../../_services/metadata.service";
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../../_models/metadata/v2/filter-statement"; import {FilterStatement} from "../../_models/metadata/v2/filter-statement";

View file

@ -1104,11 +1104,16 @@
}, },
"browse-authors": { "browse-authors": {
"title": "Browse Authors & Writers", "title": "Browse People",
"author-count": "{{num}} People", "author-count": "{{num}} People",
"cover-image-description": "{{edit-series-modal.cover-image-description}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}",
"issue-count": "{{common.issue-count}}", "issue-count": "{{common.issue-count}}",
"series-count": "{{common.series-count}}" "series-count": "{{common.series-count}}",
"roles-label": "Roles",
"sort-label": "Sort",
"name-label": "Name",
"issue-count-label": "Issue Count",
"series-count-label": "Series Count"
}, },
"browse-genres": { "browse-genres": {
@ -1883,7 +1888,9 @@
"all-filters": "Smart Filters", "all-filters": "Smart Filters",
"nav-link-header": "Navigation Options", "nav-link-header": "Navigation Options",
"close": "{{common.close}}", "close": "{{common.close}}",
"person-aka-status": "Matches an alias" "person-aka-status": "Matches an alias",
"browse-genres": "Browse Genres",
"browse-tags": "Browse Tags"
}, },
"promoted-icon": { "promoted-icon": {