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:
parent
7ef95b3e12
commit
a7d4b11593
45 changed files with 326 additions and 98 deletions
|
|
@ -9,6 +9,7 @@ using API.Data.Repositories;
|
|||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Person;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ using API.Data;
|
|||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Metadata.Browse.Requests;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
|
@ -78,11 +80,11 @@ public class PersonController : BaseApiController
|
|||
/// <param name="userParams"></param>
|
||||
/// <returns></returns>
|
||||
[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;
|
||||
|
||||
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);
|
||||
|
||||
return Ok(list);
|
||||
|
|
|
|||
8
API/DTOs/Filtering/PersonSortField.cs
Normal file
8
API/DTOs/Filtering/PersonSortField.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace API.DTOs.Filtering;
|
||||
|
||||
public enum PersonSortField
|
||||
{
|
||||
Name = 1,
|
||||
SeriesCount = 2,
|
||||
ChapterCount = 3
|
||||
}
|
||||
|
|
@ -8,3 +8,12 @@ public sealed record SortOptions
|
|||
public SortField SortField { get; set; }
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
namespace API.DTOs.Metadata;
|
||||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
public sealed record BrowseGenreDto : GenreTagDto
|
||||
{
|
||||
|
|
@ -7,7 +7,7 @@ public sealed record BrowseGenreDto : GenreTagDto
|
|||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Issues this Entity is on
|
||||
/// Number of Chapters this Entity is on
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
using API.DTOs.Person;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
/// <summary>
|
||||
/// Used to browse writers and click in to see their series
|
||||
|
|
@ -14,5 +14,5 @@ public class BrowsePersonDto : PersonDto
|
|||
/// <summary>
|
||||
/// Number of Issues this Person is the Writer for
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
using API.Entities;
|
||||
|
||||
namespace API.DTOs.Metadata;
|
||||
namespace API.DTOs.Metadata.Browse;
|
||||
|
||||
public sealed record BrowseTagDto : TagDto
|
||||
{
|
||||
|
|
@ -9,7 +7,7 @@ public sealed record BrowseTagDto : TagDto
|
|||
/// </summary>
|
||||
public int SeriesCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Issues this Entity is on
|
||||
/// Number of Chapters this Entity is on
|
||||
/// </summary>
|
||||
public int IssueCount { get; set; }
|
||||
public int ChapterCount { get; set; }
|
||||
}
|
||||
13
API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs
Normal file
13
API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
|
|
@ -182,7 +183,7 @@ public class GenreRepository : IGenreRepository
|
|||
.Select(sm => sm.Id)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
IssueCount = g.Chapters
|
||||
ChapterCount = g.Chapters
|
||||
.Select(ch => ch.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ using System.Linq;
|
|||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.DTOs.Metadata.Browse.Requests;
|
||||
using API.DTOs.Person;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
|
|
@ -46,7 +48,7 @@ public interface IPersonRepository
|
|||
Task<string?> GetCoverImageAsync(int personId);
|
||||
Task<string?> GetCoverImageByNameAsync(string name);
|
||||
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<PersonDto?> GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases);
|
||||
/// <summary>
|
||||
|
|
@ -195,13 +197,15 @@ public class PersonRepository : IPersonRepository
|
|||
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 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)
|
||||
.SortBy(filter.SortOptions)
|
||||
.Select(p => new BrowsePersonDto
|
||||
{
|
||||
Id = p.Id,
|
||||
|
|
@ -209,17 +213,17 @@ public class PersonRepository : IPersonRepository
|
|||
Description = p.Description,
|
||||
CoverImage = p.CoverImage,
|
||||
SeriesCount = p.SeriesMetadataPeople
|
||||
.Where(smp => roles.Contains(smp.Role))
|
||||
.Where(smp => filter.Roles.Contains(smp.Role))
|
||||
.Select(smp => smp.SeriesMetadata.SeriesId)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
IssueCount = p.ChapterPeople
|
||||
.Where(cp => roles.Contains(cp.Role))
|
||||
ChapterCount = p.ChapterPeople
|
||||
.Where(cp => filter.Roles.Contains(cp.Role))
|
||||
.Select(cp => cp.Chapter.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
})
|
||||
.OrderBy(p => p.Name);
|
||||
;
|
||||
|
||||
return await PagedList<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
|
|
@ -120,7 +121,7 @@ public class TagRepository : ITagRepository
|
|||
.Select(sm => sm.Id)
|
||||
.Distinct()
|
||||
.Count(),
|
||||
IssueCount = g.Chapters
|
||||
ChapterCount = g.Chapters
|
||||
.Select(ch => ch.Id)
|
||||
.Distinct()
|
||||
.Count()
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ using System.Linq.Expressions;
|
|||
using System.Threading.Tasks;
|
||||
using API.Data.Misc;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.KavitaPlus.Manage;
|
||||
using API.DTOs.Metadata.Browse;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Person;
|
||||
using API.Entities.Scrobble;
|
||||
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>
|
||||
/// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import {Genre} from "../genre";
|
|||
|
||||
export interface BrowseGenre extends Genre {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {Person} from "../metadata/person";
|
||||
import {Person} from "../person";
|
||||
|
||||
export interface BrowsePerson extends Person {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
@ -2,5 +2,5 @@ import {Tag} from "../../tag";
|
|||
|
||||
export interface BrowseTag extends Tag {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ export interface Language {
|
|||
}
|
||||
|
||||
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;
|
||||
translationCompletion: number;
|
||||
isRtL: boolean;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover";
|
|||
|
||||
export enum PersonRole {
|
||||
Other = 1,
|
||||
Artist = 2,
|
||||
Writer = 3,
|
||||
Penciller = 4,
|
||||
Inker = 5,
|
||||
|
|
@ -32,3 +31,22 @@ export interface Person extends IHasCover {
|
|||
primaryColor: 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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ export interface FilterItem<T> {
|
|||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
sortField: SortField;
|
||||
isAscending: boolean;
|
||||
}
|
||||
|
||||
export enum SortField {
|
||||
SortName = 1,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import {PersonRole} from "../person";
|
||||
import {PersonSortOptions} from "./sort-options";
|
||||
|
||||
export interface BrowsePersonFilter {
|
||||
roles: Array<PersonRole>;
|
||||
query?: string;
|
||||
sortOptions?: PersonSortOptions;
|
||||
}
|
||||
|
|
@ -66,7 +66,6 @@ export const allPeople = [
|
|||
|
||||
export const personRoleForFilterField = (role: PersonRole) => {
|
||||
switch (role) {
|
||||
case PersonRole.Artist: return FilterField.CoverArtist;
|
||||
case PersonRole.Character: return FilterField.Characters;
|
||||
case PersonRole.Colorist: return FilterField.Colorist;
|
||||
case PersonRole.CoverArtist: return FilterField.CoverArtist;
|
||||
|
|
|
|||
5
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal file
5
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum PersonSortField {
|
||||
Name = 1,
|
||||
SeriesCount = 2,
|
||||
ChapterCount = 3
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { SortOptions } from "../series-filter";
|
||||
import {FilterStatement} from "./filter-statement";
|
||||
import {FilterCombination} from "./filter-combination";
|
||||
import {SortOptions} from "./sort-options";
|
||||
|
||||
export interface SeriesFilterV2 {
|
||||
name?: string;
|
||||
|
|
|
|||
18
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal file
18
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import { PersonRole } from '../_models/metadata/person';
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {PersonRole} from '../_models/metadata/person';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'personRole',
|
||||
|
|
@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform {
|
|||
|
||||
transform(value: PersonRole): string {
|
||||
switch (value) {
|
||||
case PersonRole.Artist:
|
||||
return translate('person-role-pipe.artist');
|
||||
case PersonRole.Character:
|
||||
return translate('person-role-pipe.character');
|
||||
case PersonRole.Colorist:
|
||||
|
|
|
|||
|
|
@ -178,8 +178,6 @@ export class MetadataService {
|
|||
switch (role) {
|
||||
case PersonRole.Other:
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
entity.coverArtists = persons;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import {PaginatedResult} from "../_models/pagination";
|
|||
import {Series} from "../_models/series";
|
||||
import {map} from "rxjs/operators";
|
||||
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 {TextResonse} from "../_types/text-response";
|
||||
import {BrowsePersonFilter} from "../_models/metadata/v2/browse-person-filter";
|
||||
|
||||
@Injectable({
|
||||
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}`);
|
||||
}
|
||||
|
||||
getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) {
|
||||
getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
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) => {
|
||||
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
<div class="tag-name">{{ item.title }}</div>
|
||||
<div class="tag-meta">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
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 {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,44 @@
|
|||
|
||||
</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
|
||||
[isLoading]="isLoading"
|
||||
[items]="authors"
|
||||
|
|
@ -15,14 +53,15 @@
|
|||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[customSort]="filterGroup.get('sortField')!.value !== PersonSortField.Name"
|
||||
[refresh]="refresh"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-person-card [entity]="item" [title]="item.name" [imageUrl]="imageService.getPersonImage(item.id)" (clicked)="goToPerson(item)">
|
||||
<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('issue-count', {num: item.issueCount | compactNumber})}}</div>
|
||||
<div style="font-size: 12px">{{t('issue-count', {num: item.chapterCount | compactNumber})}}</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-person-card>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
||||
.main-container {
|
||||
margin-top: 10px;
|
||||
padding: 0 0 0 10px;
|
||||
|
|
|
|||
|
|
@ -17,12 +17,21 @@ import {Pagination} from "../../_models/pagination";
|
|||
import {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {Router} from "@angular/router";
|
||||
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 {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 {PersonSortField} from "../../_models/metadata/v2/person-sort-field";
|
||||
import {PersonSortOptions} from "../../_models/metadata/v2/sort-options";
|
||||
|
||||
|
||||
@Component({
|
||||
|
|
@ -34,15 +43,19 @@ import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
|||
DecimalPipe,
|
||||
PersonCardComponent,
|
||||
CompactNumberPipe,
|
||||
Select2,
|
||||
ReactiveFormsModule,
|
||||
SortButtonComponent,
|
||||
],
|
||||
templateUrl: './browse-authors.component.html',
|
||||
styleUrl: './browse-authors.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BrowseAuthorsComponent implements OnInit {
|
||||
protected readonly PersonSortField = PersonSortField;
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly personService = inject(PersonService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
|
|
@ -56,12 +69,41 @@ export class BrowseAuthorsComponent implements OnInit {
|
|||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
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() {
|
||||
this.isLoading = true;
|
||||
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.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
|
||||
this.isLoading = false;
|
||||
|
|
@ -72,5 +114,4 @@ export class BrowseAuthorsComponent implements OnInit {
|
|||
goToPerson(person: BrowsePerson) {
|
||||
this.router.navigate(['person', person.name]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<div class="tag-name">{{ item.title }}</div>
|
||||
<div class="tag-meta">
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.se
|
|||
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
|
||||
import {Pagination} from "../../_models/pagination";
|
||||
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 {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {BrowseTag} from "../../_models/metadata/browse/browse-tag";
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
@Input() filterSettings!: FilterSettings;
|
||||
@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
|
||||
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
|
||||
|
||||
|
|
@ -109,6 +112,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
|
||||
|
||||
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||
|
||||
|
||||
|
|
@ -171,11 +175,10 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
hasCustomSort() {
|
||||
if (this.customSort) return true;
|
||||
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>) {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import {NextExpectedChapter} from "../../_models/series-detail/next-expected-cha
|
|||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.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";
|
||||
|
||||
export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {ScrollService} from "../../_services/scroll.service";
|
|||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
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 {FormsModule} from "@angular/forms";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
|
|
|
|||
|
|
@ -41,13 +41,7 @@
|
|||
</div>
|
||||
<div class="col-md-3 col-sm-9">
|
||||
<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">
|
||||
@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>
|
||||
<app-sort-button [disabled]="filterSettings.sortDisabled" (update)="updateSortOrder($event)" [isAscending]="isAscendingSort" />
|
||||
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
|
||||
@for(field of allSortFields; track field.value) {
|
||||
<option [value]="field.value">{{field.title}}</option>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {allFields} 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";
|
||||
import {SortButtonComponent} from "../_single-module/sort-button/sort-button.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-filter',
|
||||
|
|
@ -46,9 +47,9 @@ import {animate, style, transition, trigger} from "@angular/animations";
|
|||
]),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgTemplateOutlet, DrawerComponent,
|
||||
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
|
||||
MetadataBuilderComponent, NgClass]
|
||||
imports: [NgTemplateOutlet, DrawerComponent,
|
||||
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
|
||||
MetadataBuilderComponent, NgClass, SortButtonComponent]
|
||||
})
|
||||
export class MetadataFilterComponent implements OnInit {
|
||||
|
||||
|
|
@ -185,9 +186,10 @@ export class MetadataFilterComponent implements OnInit {
|
|||
}
|
||||
|
||||
|
||||
updateSortOrder() {
|
||||
updateSortOrder(isAscending: boolean) {
|
||||
if (this.filterSettings.sortDisabled) return;
|
||||
this.isAscendingSort = !this.isAscendingSort;
|
||||
this.isAscendingSort = isAscending;
|
||||
|
||||
if (this.filterV2?.sortOptions === null) {
|
||||
this.filterV2.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
|
|
|
|||
|
|
@ -206,6 +206,8 @@
|
|||
</button>
|
||||
<div ngbDropdownMenu>
|
||||
<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 [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
|
||||
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Inject, Injectable} from '@angular/core';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ConfirmService } from '../confirm.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import {
|
||||
asyncScheduler,
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
tap,
|
||||
finalize,
|
||||
of,
|
||||
filter,
|
||||
} from 'rxjs';
|
||||
import { download, Download } from '../_models/download';
|
||||
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {ConfirmService} from '../confirm.service';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {Volume} from 'src/app/_models/volume';
|
||||
import {asyncScheduler, BehaviorSubject, filter, finalize, Observable, of, tap,} from 'rxjs';
|
||||
import {download, Download} from '../_models/download';
|
||||
import {PageBookmark} from 'src/app/_models/readers/page-bookmark';
|
||||
import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { BytesPipe } from 'src/app/_pipes/bytes.pipe';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {BytesPipe} from 'src/app/_pipes/bytes.pipe';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SAVER, Saver} from "../../_providers/saver.provider";
|
||||
|
|
@ -26,7 +18,7 @@ import {UtilityService} from "./utility.service";
|
|||
import {UserCollection} from "../../_models/collection-tag";
|
||||
import {RecentlyAddedItem} from "../../_models/recently-added-item";
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import {inject, Injectable} from '@angular/core';
|
||||
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 {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
|
||||
import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {TextResonse} from "../../_types/text-response";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {map, tap} from "rxjs/operators";
|
||||
|
|
|
|||
|
|
@ -1104,11 +1104,16 @@
|
|||
},
|
||||
|
||||
"browse-authors": {
|
||||
"title": "Browse Authors & Writers",
|
||||
"title": "Browse People",
|
||||
"author-count": "{{num}} People",
|
||||
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
||||
"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": {
|
||||
|
|
@ -1883,7 +1888,9 @@
|
|||
"all-filters": "Smart Filters",
|
||||
"nav-link-header": "Navigation Options",
|
||||
"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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue