New Scanner + People Pages (#3286)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-10-23 15:11:18 -07:00 committed by GitHub
parent 1ed0eae22d
commit ba20ad4ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 17529 additions and 3038 deletions

View file

@ -125,7 +125,6 @@ $image-width: 160px;
.overlay-information--centered {
position: absolute;
border-radius: 15px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50px;
top: 50%;
@ -169,7 +168,7 @@ $image-width: 160px;
margin: 0;
text-align: center;
max-width: 98px;
a {
overflow: hidden;
text-overflow: ellipsis;

View file

@ -17,7 +17,15 @@ export enum PersonRole {
}
export interface Person {
id: number;
name: string;
role: PersonRole;
id: number;
name: string;
description: string;
coverImage?: string;
coverImageLocked: boolean;
malId?: number;
aniListId?: number;
hardcoverId?: string;
asin?: string;
primaryColor?: string;
secondaryColor?: string;
}

View file

@ -1,3 +1,5 @@
import {PersonRole} from "../person";
export enum FilterField
{
None = -1,
@ -47,3 +49,36 @@ enumArray.sort((a, b) => a.value.localeCompare(b.value));
export const allFields = enumArray
.map(key => parseInt(key.key, 10))as FilterField[];
export const allPeople = [
FilterField.Characters,
FilterField.Colorist,
FilterField.CoverArtist,
FilterField.Editor,
FilterField.Inker,
FilterField.Letterer,
FilterField.Penciller,
FilterField.Publisher,
FilterField.Translators,
FilterField.Writers,
];
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;
case PersonRole.Editor: return FilterField.Editor;
case PersonRole.Inker: return FilterField.Inker;
case PersonRole.Letterer: return FilterField.Letterer;
case PersonRole.Penciller: return FilterField.Penciller;
case PersonRole.Publisher: return FilterField.Publisher;
case PersonRole.Translator: return FilterField.Translators;
case PersonRole.Writer: return FilterField.Writers;
case PersonRole.Imprint: return FilterField.Imprint;
case PersonRole.Location: return FilterField.Location;
case PersonRole.Team: return FilterField.Team;
case PersonRole.Other: return FilterField.None;
}
};

View file

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

View file

@ -4,14 +4,18 @@ import { MangaFile } from "../manga-file";
import { SearchResult } from "./search-result";
import { Tag } from "../tag";
import {BookmarkSearchResult} from "./bookmark-search-result";
import {Genre} from "../metadata/genre";
import {ReadingList} from "../reading-list";
import {UserCollection} from "../collection-tag";
import {Person} from "../metadata/person";
export class SearchResultGroup {
libraries: Array<Library> = [];
series: Array<SearchResult> = [];
collections: Array<Tag> = [];
readingLists: Array<Tag> = [];
persons: Array<Tag> = [];
genres: Array<Tag> = [];
collections: Array<UserCollection> = [];
readingLists: Array<ReadingList> = [];
persons: Array<Person> = [];
genres: Array<Genre> = [];
tags: Array<Tag> = [];
files: Array<MangaFile> = [];
chapters: Array<Chapter> = [];

View file

@ -7,4 +7,5 @@ export enum SideNavStreamType {
ExternalSource = 6,
AllSeries = 7,
WantToRead = 8,
BrowseAuthors = 9
}

View file

@ -1,5 +1,5 @@
import {SideNavStreamType} from "./sidenav-stream-type.enum";
import {Library, LibraryType} from "../library/library";
import {Library} from "../library/library";
import {CommonStream} from "../common-stream";
import {ExternalSource} from "./external-source";

View file

@ -0,0 +1,9 @@
import {Chapter} from "./chapter";
import {LibraryType} from "./library/library";
export interface StandaloneChapter extends Chapter {
seriesId: number;
libraryId: number;
libraryType: LibraryType;
volumeTitle?: string;
}

View file

@ -18,7 +18,7 @@ export class PersonRolePipe implements PipeTransform {
case PersonRole.Colorist:
return this.translocoService.translate('person-role-pipe.colorist');
case PersonRole.CoverArtist:
return this.translocoService.translate('person-role-pipe.cover-artist');
return this.translocoService.translate('person-role-pipe.artist');
case PersonRole.Editor:
return this.translocoService.translate('person-role-pipe.editor');
case PersonRole.Inker:

View file

@ -0,0 +1,8 @@
import { Routes } from "@angular/router";
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";
export const routes: Routes = [
{path: '', component: BrowseAuthorsComponent, pathMatch: 'full'},
];

View file

@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
import { AuthGuard } from '../_guards/auth.guard';
import {PersonDetailComponent} from "../person-detail/person-detail.component";
export const routes: Routes = [
{
path: ':name',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
component: PersonDetailComponent
},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
component: PersonDetailComponent
}
];

View file

@ -12,6 +12,7 @@ import { DeviceService } from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person";
export enum Action {
Submenu = -1,
@ -160,6 +161,8 @@ export class ActionFactoryService {
bookmarkActions: Array<ActionItem<Series>> = [];
private personActions: Array<ActionItem<Person>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
@ -180,11 +183,11 @@ export class ActionFactoryService {
}
getLibraryActions(callback: ActionCallback<Library>) {
return this.applyCallbackToList(this.libraryActions, callback);
return this.applyCallbackToList(this.libraryActions, callback);
}
getSeriesActions(callback: ActionCallback<Series>) {
return this.applyCallbackToList(this.seriesActions, callback);
return this.applyCallbackToList(this.seriesActions, callback);
}
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
@ -196,7 +199,7 @@ export class ActionFactoryService {
}
getVolumeActions(callback: ActionCallback<Volume>) {
return this.applyCallbackToList(this.volumeActions, callback);
return this.applyCallbackToList(this.volumeActions, callback);
}
getChapterActions(callback: ActionCallback<Chapter>) {
@ -204,7 +207,7 @@ export class ActionFactoryService {
}
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
return this.applyCallbackToList(this.collectionTagActions, callback);
return this.applyCallbackToList(this.collectionTagActions, callback);
}
getReadingListActions(callback: ActionCallback<ReadingList>) {
@ -215,6 +218,10 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.bookmarkActions, callback);
}
getPersonActions(callback: ActionCallback<Person>) {
return this.applyCallbackToList(this.personActions, callback);
}
dummyCallback(action: ActionItem<any>, data: any) {}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
@ -424,7 +431,7 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
{
action: Action.AddToWantToReadList,
title: 'add-to-want-to-read',
description: 'add-to-want-to-read-tooltip',
@ -579,23 +586,23 @@ export class ActionFactoryService {
requiresAdmin: false,
children: [],
},
{
action: Action.Submenu,
title: 'add-to',
{
action: Action.Submenu,
title: 'add-to',
description: '=',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
{
action: Action.Submenu,
title: 'send-to',
@ -676,23 +683,23 @@ export class ActionFactoryService {
requiresAdmin: false,
children: [],
},
{
action: Action.Submenu,
title: 'add-to',
{
action: Action.Submenu,
title: 'add-to',
description: '',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
{
action: Action.Submenu,
title: 'send-to',
@ -785,6 +792,17 @@ export class ActionFactoryService {
},
];
this.personActions = [
{
action: Action.Edit,
title: 'edit',
description: 'edit-person-tooltip',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
}
];
this.bookmarkActions = [
{
action: Action.ViewSeries,
@ -854,13 +872,13 @@ export class ActionFactoryService {
});
}
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});
actions.forEach((action) => this.applyCallback(action, callback));
return actions;
}
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});
actions.forEach((action) => this.applyCallback(action, callback));
return actions;
}
// Checks the whole tree for the action and returns true if it exists
public hasAction(actions: Array<ActionItem<any>>, action: Action) {

View file

@ -17,18 +17,21 @@ export class ImageService {
public errorImage = 'assets/images/error-placeholder2.dark-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
public errorWebLinkImage = 'assets/images/broken-white-32x32.png';
public nextChapterImage = 'assets/images/image-placeholder.dark-min.png'
public nextChapterImage = 'assets/images/image-placeholder.dark-min.png';
public noPersonImage = 'assets/images/error-person-missing.dark.min.png';
constructor(private accountService: AccountService, private themeService: ThemeService) {
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => {
if (this.themeService.isDarkTheme()) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
this.errorWebLinkImage = 'assets/images/broken-white-32x32.png';
this.errorWebLinkImage = 'assets/images/broken-black-32x32.png';
this.noPersonImage = 'assets/images/error-person-missing.dark.min.png';
} else {
this.placeholderImage = 'assets/images/image-placeholder-min.png';
this.errorImage = 'assets/images/error-placeholder2-min.png';
this.errorWebLinkImage = 'assets/images/broken-black-32x32.png';
this.errorWebLinkImage = 'assets/images/broken-white-32x32.png';
this.noPersonImage = 'assets/images/error-person-missing.min.png';
}
});
@ -59,6 +62,13 @@ export class ImageService {
return part.substring(0, equalIndex).replace('Id', '');
}
getPersonImage(personId: number) {
return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey}`;
}
getPersonImageByName(name: string) {
return `${this.baseUrl}image/person-cover-by-name?name=${name}&apiKey=${this.encodedKey}`;
}
getLibraryCoverImage(libraryId: number) {
return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey}`;
}

View file

@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import {HttpClient, HttpParams} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {Person, PersonRole} from "../_models/metadata/person";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
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 {Chapter} from "../_models/chapter";
import {StandaloneChapter} from "../_models/standalone-chapter";
@Injectable({
providedIn: 'root'
})
export class PersonService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
updatePerson(person: Person) {
return this.httpClient.post<Person>(this.baseUrl + "person/update", person);
}
get(name: string) {
return this.httpClient.get<Person>(this.baseUrl + `person?name=${name}`);
}
getRolesForPerson(name: string) {
return this.httpClient.get<Array<PersonRole>>(this.baseUrl + `person/roles?name=${name}`);
}
getSeriesMostKnownFor(personId: number) {
return this.httpClient.get<Array<Series>>(this.baseUrl + `person/series-known-for?personId=${personId}`);
}
getChaptersByRole(personId: number, role: PersonRole) {
return this.httpClient.get<Array<StandaloneChapter>>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`);
}
getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/authors', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
})
);
}
}

View file

@ -195,7 +195,7 @@ export class ThemeService {
* @param entity
* @param id
*/
refreshColorScape(entity: 'series' | 'volume' | 'chapter', id: number) {
refreshColorScape(entity: 'series' | 'volume' | 'chapter' | 'person', id: number) {
return this.httpClient.get<ColorScape>(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => {
this.setColorScape(cs.primary || '', cs.secondary);
}));

View file

@ -64,6 +64,12 @@ export class UploadService {
}));
}
updatePersonCoverImage(personId: number, url: string, lockCover: boolean = true) {
return this.httpClient.post<number>(this.baseUrl + 'upload/person', {id: personId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
this.toastr.info(translate('series-detail.cover-change'));
}));
}
resetChapterCoverLock(chapterId: number, ) {
return this.httpClient.post<number>(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''});
}

View file

@ -41,7 +41,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.writers" [title]="t('writers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Writers, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -49,7 +49,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.colorists" [title]="t('colorists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Colorist, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -57,7 +57,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.editors" [title]="t('editors-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Editor, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -66,7 +66,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.coverArtists" [title]="t('cover-artists-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.CoverArtist, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -74,7 +74,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.inkers" [title]="t('inkers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Inker, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -82,7 +82,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.letterers" [title]="t('letterers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Letterer, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -90,7 +90,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.pencillers" [title]="t('pencillers-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Penciller, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -98,7 +98,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.translators" [title]="t('translators-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Translators, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -106,7 +106,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.characters" [title]="t('characters-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Characters, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -114,7 +114,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.locations" [title]="t('locations-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Location, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -122,7 +122,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.teams" [title]="t('teams-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Team, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>
@ -130,7 +130,7 @@
<div class="mb-3">
<app-carousel-reel [items]="metadata.imprints" [title]="t('imprints-title')">
<ng-template #carouselItem let-item>
<app-person-badge [person]="item" (click)="openPerson(FilterField.Imprint, item)"></app-person-badge>
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-carousel-reel>
</div>

View file

@ -46,16 +46,8 @@ export class DetailsTabComponent {
@Input() webLinks: Array<string> = [];
openPerson(queryParamName: FilterField, filter: Person) {
if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter.id}`).subscribe();
}
openGeneric(queryParamName: FilterField, filter: string | number) {
if (queryParamName === FilterField.None) return;
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
}
protected readonly TagBadgeCursor = TagBadgeCursor;
}

View file

@ -192,7 +192,7 @@ export class EditChapterModalComponent implements OnInit {
})).subscribe();
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
this.editForm.addControl('sortOrder', new FormControl(Math.max(0, this.chapter.sortOrder), [Validators.required, Validators.min(0)]));
this.editForm.addControl('summary', new FormControl(this.chapter.summary || '', []));
this.editForm.addControl('language', new FormControl(this.chapter.language, []));
this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, []));
@ -466,12 +466,12 @@ export class EditChapterModalComponent implements OnInit {
fetchPeople(role: PersonRole, filter: string) {
return this.metadataService.getAllPeople().pipe(map(people => {
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
return people.filter(p => this.utilityService.filter(p.name, filter));
}));
}
createBlankPersonSettings(id: string, role: PersonRole) {
var personSettings = new TypeaheadSettings<Person>();
let personSettings = new TypeaheadSettings<Person>();
personSettings.minCharacters = 0;
personSettings.multiple = true;
personSettings.showLocked = true;
@ -486,14 +486,14 @@ export class EditChapterModalComponent implements OnInit {
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
return a.name == b.name;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role };
return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false };
});
return personSettings;

View file

@ -46,6 +46,14 @@ const routes: Routes = [
path: 'home',
loadChildren: () => import('./_routes/dashboard-routing.module').then(m => m.routes)
},
{
path: 'person',
loadChildren: () => import('./_routes/person-detail-routing.module').then(m => m.routes)
},
{
path: 'browse/authors',
loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes)
},
{
path: 'library',
runGuardsAndResolvers: 'always',

View file

@ -0,0 +1,33 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-authors'" >
<app-side-nav-companion-bar [hasFilter]="false">
<h2 title>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle>{{t('author-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="authors"
[pagination]="pagination"
[trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[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 style="font-size: 12px">{{item.seriesCount | compactNumber}} series</div>
<div style="font-size: 12px">{{item.issueCount | compactNumber}} issues</div>
</div>
</ng-template>
</app-person-card>
</ng-template>
</app-card-detail-layout>
</ng-container>
</div>

View file

@ -0,0 +1,4 @@
.main-container {
margin-top: 10px;
padding: 0 0 0 10px;
}

View file

@ -0,0 +1,90 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit
} from '@angular/core';
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {Series} from "../_models/series";
import {Pagination} from "../_models/pagination";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {ActivatedRoute, Router} from "@angular/router";
import {Title} from "@angular/platform-browser";
import {ActionFactoryService} from "../_services/action-factory.service";
import {ActionService} from "../_services/action.service";
import {MessageHubService} from "../_services/message-hub.service";
import {UtilityService} from "../shared/_services/utility.service";
import {PersonService} from "../_services/person.service";
import {BrowsePerson} from "../_models/person/browse-person";
import {CardItemComponent} from "../cards/card-item/card-item.component";
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";
@Component({
selector: 'app-browse-authors',
standalone: true,
imports: [
SideNavCompanionBarComponent,
TranslocoDirective,
CardDetailLayoutComponent,
DecimalPipe,
CardItemComponent,
PersonCardComponent,
CompactNumberPipe,
],
templateUrl: './browse-authors.component.html',
styleUrl: './browse-authors.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BrowseAuthorsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly titleService = inject(Title);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly personService = inject(PersonService);
private readonly jumpbarService = inject(JumpbarService);
protected readonly imageService = inject(ImageService);
series: Series[] = [];
isLoading = false;
authors: Array<BrowsePerson> = [];
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
ngOnInit() {
this.isLoading = true;
this.cdRef.markForCheck();
this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => {
this.authors = d.result;
this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
this.isLoading = false;
this.cdRef.markForCheck();
});
}
goToPerson(person: BrowsePerson) {
this.router.navigate(['person', person.name]);
}
}

View file

@ -488,12 +488,12 @@ export class EditSeriesModalComponent implements OnInit {
fetchPeople(role: PersonRole, filter: string) {
return this.metadataService.getAllPeople().pipe(map(people => {
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
return people.filter(p => this.utilityService.filter(p.name, filter));
}));
}
createBlankPersonSettings(id: string, role: PersonRole) {
var personSettings = new TypeaheadSettings<Person>();
const personSettings = new TypeaheadSettings<Person>();
personSettings.minCharacters = 0;
personSettings.multiple = true;
personSettings.showLocked = true;
@ -508,14 +508,14 @@ export class EditSeriesModalComponent implements OnInit {
}
personSettings.selectionCompareFn = (a: Person, b: Person) => {
return a.name == b.name && a.role == b.role;
return a.name == b.name;
}
personSettings.fetchFn = (filter: string) => {
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
};
personSettings.addTransformFn = ((title: string) => {
return {id: 0, name: title, role: role };
return {id: 0, name: title, description: '', coverImageLocked: false };
});
return personSettings;

View file

@ -46,6 +46,9 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {SafeHtmlPipe} from "../../_pipes/safe-html.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";
export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson;
@Component({
selector: 'app-card-item',
@ -116,7 +119,7 @@ export class CardItemComponent implements OnInit {
/**
* This is the entity we are representing. It will be returned if an action is executed.
*/
@Input({required: true}) entity!: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
@Input({required: true}) entity!: CardEntity;
/**
* If the entity is selected or not.
*/
@ -161,7 +164,7 @@ export class CardItemComponent implements OnInit {
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
@Output() readClicked = new EventEmitter<Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter>();
@Output() readClicked = new EventEmitter<CardEntity>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
/**
* Library name item belongs to

View file

@ -0,0 +1,49 @@
<ng-container *transloco="let t; read: 'card-item'">
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)">
@if(entity.coverImage) {
<app-image [imageUrl]="imageUrl" [errorImage]="imageService.noPersonImage" [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="158px" width="158px"></app-image>
} @else {
<div class="missing-img mx-auto">
<i class="fas fa-user fs-2" aria-hidden="true"></i>
</div>
}
@if (allowSelection) {
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)">
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
}
@if (count > 1) {
<div class="count">
<span class="badge bg-primary">{{count}}</span>
</div>
}
<div class="card-overlay"></div>
</div>
@if (title.length > 0 || actions.length > 0) {
<div class="card-body">
<div>
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
{{title}}
</span>
@if (actions && actions.length > 0) {
<span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
</span>
}
</div>
@if (subtitleTemplate) {
<div style="text-align: center">
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
</div>
}
</div>
}
</div>
</ng-container>

View file

@ -0,0 +1,31 @@
$image-height: 160px;
@use '../../../card-item-common';
// Override so we can have square cards
.bulk-mode {
&.always-show {
height: $image-height;
}
}
.card-item-container {
.overlay {
height: $image-height;
/* TODO: Robbie fix this hack */
.missing-img {
position: absolute;
left: 43%;
top: 25%;
}
}
.card-overlay {
height: $image-height;
}
.card-body {
bottom: 0;
}
}

View file

@ -0,0 +1,157 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, ContentChild,
DestroyRef, EventEmitter,
HostListener,
inject,
Input, Output, TemplateRef
} from '@angular/core';
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ImageService} from "../../_services/image.service";
import {BulkSelectionService} from "../bulk-selection.service";
import {LibraryService} from "../../_services/library.service";
import {DownloadService} from "../../shared/_services/download.service";
import {UtilityService} from "../../shared/_services/utility.service";
import {MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
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 {Person} from "../../_models/metadata/person";
import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component";
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-person-card',
standalone: true,
imports: [
NgbTooltip,
CardActionablesComponent,
NgTemplateOutlet,
FormsModule,
ImageComponent,
TranslocoDirective
],
templateUrl: './person-card.component.html',
styleUrl: './person-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonCardComponent {
private readonly destroyRef = inject(DestroyRef);
public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly messageHub = inject(MessageHubService);
private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef);
/**
* Card item url. Will internally handle error and missing covers
*/
@Input() imageUrl = '';
/**
* Name of the card
*/
@Input() title = '';
/**
* If the entity is selected or not.
*/
@Input() selected: boolean = false;
/**
* Any actions to perform on the card
*/
@Input() actions: ActionItem<any>[] = [];
/**
* This is the entity we are representing. It will be returned if an action is executed.
*/
@Input({required: true}) entity!: BrowsePerson | Person;
/**
* If the entity should show selection code
*/
@Input() allowSelection: boolean = false;
/**
* The number of updates/items within the card. If less than 2, will not be shown.
*/
@Input() count: number = 0;
/**
* Event emitted when item is clicked
*/
@Output() clicked = new EventEmitter<string>();
/**
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
tooltipTitle: string = this.title;
/**
* Handles touch events for selection on mobile devices
*/
prevTouchTime: number = 0;
/**
* Handles touch events for selection on mobile devices to ensure you aren't touch scrolling
*/
prevOffset: number = 0;
selectionInProgress: boolean = false;
@HostListener('touchmove', ['$event'])
onTouchMove(event: TouchEvent) {
if (!this.allowSelection) return;
this.selectionInProgress = false;
this.cdRef.markForCheck();
}
@HostListener('touchstart', ['$event'])
onTouchStart(event: TouchEvent) {
if (!this.allowSelection) return;
this.prevTouchTime = event.timeStamp;
this.prevOffset = this.scrollService.scrollPosition;
this.selectionInProgress = true;
}
@HostListener('touchend', ['$event'])
onTouchEnd(event: TouchEvent) {
if (!this.allowSelection) return;
const delta = event.timeStamp - this.prevTouchTime;
const verticalOffset = this.scrollService.scrollPosition;
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset) && this.selectionInProgress) {
this.handleSelection();
event.stopPropagation();
event.preventDefault();
}
this.prevTouchTime = 0;
this.selectionInProgress = false;
}
handleClick(event?: any) {
if (this.bulkSelectionService.hasSelections()) {
this.handleSelection();
return;
}
this.clicked.emit(this.title);
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
handleSelection(event?: any) {
if (event) {
event.stopPropagation();
}
this.selection.emit(this.selected);
this.cdRef.detectChanges();
}
}

View file

@ -14,20 +14,20 @@
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
@if (filter) {
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
[isLoading]="loadingSeries"
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
[refresh]="refresh"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [series]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>
}

View file

@ -47,11 +47,11 @@ import {LoadingComponent} from "../shared/loading/loading.component";
import {debounceTime, ReplaySubject, tap} from "rxjs";
@Component({
selector: 'app-library-detail',
templateUrl: './library-detail.component.html',
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
selector: 'app-library-detail',
templateUrl: './library-detail.component.html',
styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent,
CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
})
@ -321,11 +321,11 @@ export class LibraryDetailComponent implements OnInit {
this.seriesService.getSeriesForLibraryV2(undefined, undefined, this.filter)
.subscribe(series => {
this.series = series.result;
this.pagination = series.pagination;
this.loadingSeries = false;
this.cdRef.markForCheck();
});
this.series = series.result;
this.pagination = series.pagination;
this.loadingSeries = false;
this.cdRef.markForCheck();
});
}
trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`;

View file

@ -119,9 +119,7 @@ export class GroupedTypeaheadComponent implements OnInit {
@HostListener('window:click', ['$event'])
handleDocumentClick(event: MouseEvent) {
console.log('click: ', event)
this.close();
}
@HostListener('window:keydown', ['$event'])

View file

@ -108,15 +108,18 @@
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div class="text-light fst-italic">{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result"
[styles]="{'background': 'none', 'max-height': '24px', 'height': '24px', 'width': '24px', 'border-radius': '50%'}"
width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage"></app-image>
</div>
<div class="ms-1">
<div>{{item.name}}</div>
</div>
</div>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToOther(FilterField.Genres, item.id)">

View file

@ -17,7 +17,7 @@ import {Chapter} from 'src/app/_models/chapter';
import {UserCollection} from 'src/app/_models/collection-tag';
import {Library} from 'src/app/_models/library/library';
import {MangaFile} from 'src/app/_models/manga-file';
import {PersonRole} from 'src/app/_models/metadata/person';
import {Person, PersonRole} from 'src/app/_models/metadata/person';
import {ReadingList} from 'src/app/_models/reading-list';
import {SearchResult} from 'src/app/_models/search/search-result';
import {SearchResultGroup} from 'src/app/_models/search/search-result-group';
@ -178,56 +178,9 @@ export class NavHeaderComponent implements OnInit {
this.goTo({field, comparison: FilterComparison.Equal, value: value + ''});
}
goToPerson(role: PersonRole, filter: any) {
goToPerson(person: Person) {
this.clearSearch();
filter = filter + '';
switch(role) {
case PersonRole.Other:
break;
case PersonRole.Writer:
this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Artist:
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); // TODO: What is this supposed to be?
break;
case PersonRole.Character:
this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Colorist:
this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Editor:
this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Inker:
this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.CoverArtist:
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Letterer:
this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Penciller:
this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Publisher:
this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Imprint:
this.goTo({field: FilterField.Imprint, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Team:
this.goTo({field: FilterField.Team, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Location:
this.goTo({field: FilterField.Location, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Translator:
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
break;
}
this.router.navigate(['person', person.name]);
}
clearSearch() {

View file

@ -0,0 +1,123 @@
<ng-container *transloco="let t; read: 'edit-person-modal'">
@if (person !== undefined) {
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title">
{{t('title', {personName: this.person.name})}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<form [formGroup]="editForm">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{t(TabID.General)}}</a>
<ng-template ngbNavContent>
<div class="row g-0">
<div class="mb-3">
@if (editForm.get('name'); as formControl) {
<app-setting-item [title]="t('name-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<input id="name" class="form-control" formControlName="name" type="text" readonly
[class.is-invalid]="formControl.invalid && formControl.touched">
@if (formControl.errors; as errors) {
@if (errors.required) {
<div class="invalid-feedback">{{t('required-field')}}</div>
}
}
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row g-0">
<div class="mb-3 col-md-6 col-xs-12 pe-2">
@if (editForm.get('malId'); as formControl) {
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<input id="mal-id" class="form-control" formControlName="malId" type="number"
[class.is-invalid]="formControl.invalid && formControl.touched">
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3 col-md-6 col-xs-12">
@if (editForm.get('aniListId'); as formControl) {
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<input id="anilist-id" class="form-control" formControlName="aniListId" type="number"
[class.is-invalid]="formControl.invalid && formControl.touched">
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row g-0">
<div class="mb-3 col-md-6 col-xs-12 pe-2">
@if (editForm.get('hardcoverId'); as formControl) {
<app-setting-item [title]="t('hardcover-id-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<input id="hardcover-id" class="form-control" formControlName="hardcoverId" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3 col-md-6 col-xs-12">
@if (editForm.get('asin'); as formControl) {
<app-setting-item [title]="t('asin-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<input id="asin" class="form-control" formControlName="asin" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
</ng-template>
</app-setting-item>
}
</div>
</div>
<div class="row g-0">
<div class="mb-3" style="width: 100%">
<app-setting-item [title]="t('description-label')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
<div class="input-group">
<textarea id="description" class="form-control" formControlName="description" rows="4"></textarea>
</div>
</ng-template>
</app-setting-item>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
{{t('cover-image-description')}}
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls"
(imageSelected)="updateSelectedIndex($event)"
(selectedBase64Url)="updateSelectedImage($event)"
[showReset]="person.coverImageLocked"
(resetClicked)="handleReset()">
</app-cover-image-chooser>
</ng-template>
</li>
</ul>
</form>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
<button type="submit" class="btn btn-primary" [disabled]="!editForm.valid" (click)="save()">{{t('save')}}</button>
</div>
</div>
}
</ng-container>

View file

@ -0,0 +1,151 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NgTemplateOutlet} from "@angular/common";
import {PersonRolePipe} from "../../../_pipes/person-role.pipe";
import {Person, PersonRole} from "../../../_models/metadata/person";
import {
NgbActiveModal,
NgbNav,
NgbNavContent,
NgbNavItem, NgbNavLink,
NgbNavLinkBase,
NgbNavOutlet
} from "@ng-bootstrap/ng-bootstrap";
import {PersonService} from "../../../_services/person.service";
import { TranslocoDirective } from '@jsverse/transloco';
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
import {forkJoin} from "rxjs";
import {UploadService} from "../../../_services/upload.service";
import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {AccountService} from "../../../_services/account.service";
import {User} from "../../../_models/user";
enum TabID {
General = 'general-tab',
CoverImage = 'cover-image-tab',
}
@Component({
selector: 'app-edit-person-modal',
standalone: true,
imports: [
ReactiveFormsModule,
NgTemplateOutlet,
PersonRolePipe,
NgbNav,
NgbNavItem,
TranslocoDirective,
NgbNavLinkBase,
NgbNavContent,
NgbNavOutlet,
CoverImageChooserComponent,
CompactNumberPipe,
SettingItemComponent,
NgbNavLink
],
templateUrl: './edit-person-modal.component.html',
styleUrl: './edit-person-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditPersonModalComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
private readonly modal = inject(NgbActiveModal);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly personService = inject(PersonService);
private readonly uploadService = inject(UploadService);
protected readonly accountService = inject(AccountService);
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
@Input({required: true}) person!: Person;
active = TabID.General;
editForm: FormGroup = new FormGroup({
name: new FormControl('', [Validators.required]),
description: new FormControl('', []),
asin: new FormControl('', []),
aniListId: new FormControl('', []),
malId: new FormControl('', []),
hardcoverId: new FormControl('', []),
});
imageUrls: Array<string> = [];
selectedCover: string = '';
coverImageReset = false;
touchedCoverImage = false;
ngOnInit() {
if (this.person) {
this.editForm.get('name')!.setValue(this.person.name);
this.editForm.get('description')!.setValue(this.person.description);
this.editForm.get('asin')!.setValue((this.person.asin || ''));
this.editForm.get('aniListId')!.setValue((this.person.aniListId || '') + '') ;
this.editForm.get('malId')!.setValue((this.person.malId || '') + '');
this.editForm.get('hardcoverId')!.setValue(this.person.hardcoverId || '');
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, []));
this.cdRef.markForCheck();
}
}
close() {
this.modal.close({success: false, coverImageUpdate: false});
}
save() {
const apis = [];
if (this.touchedCoverImage || this.coverImageReset) {
apis.push(this.uploadService.updatePersonCoverImage(this.person.id, this.selectedCover, !this.coverImageReset));
}
const person: Person = {
id: this.person.id,
coverImageLocked: this.person.coverImageLocked,
name: this.editForm.get('name')!.value || '',
description: this.editForm.get('description')!.value || '',
asin: this.editForm.get('asin')!.value || '',
// @ts-ignore
aniListId: this.editForm.get('aniListId')!.value === '' ? null : parseInt(this.editForm.get('aniListId').value, 10),
// @ts-ignore
malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10),
hardcoverId: this.editForm.get('hardcoverId')!.value || '',
};
apis.push(this.personService.updatePerson(person));
forkJoin(apis).subscribe(_ => {
this.modal.close({success: true, coverImageUpdate: false, person: person});
});
}
updateSelectedIndex(index: number) {
this.editForm.patchValue({
coverImageIndex: index
});
this.touchedCoverImage = true;
this.cdRef.markForCheck();
}
updateSelectedImage(url: string) {
this.selectedCover = url;
this.touchedCoverImage = true;
this.cdRef.markForCheck();
}
handleReset() {
this.coverImageReset = true;
this.editForm.patchValue({
coverImageLocked: false
});
this.touchedCoverImage = true;
this.cdRef.markForCheck();
}
}

View file

@ -0,0 +1,95 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'person-detail'">
@if (person$ | async; as person) {
<div #companionBar>
<app-side-nav-companion-bar>
<ng-container title>
<h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{person.name}}</span>
</h2>
</ng-container>
</app-side-nav-companion-bar>
</div>
<div class="main-container container-fluid pt-2 mb-5">
<div class="row mb-0 mb-xl-3 info-container">
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mt-2">
<app-image [styles]="{'background': 'none', 'max-height': '400px', 'height': '200px', 'width': '200px', 'border-radius': '50%'}"
[imageUrl]="imageService.getPersonImage(person.id)"
[errorImage]="imageService.noPersonImage"></app-image>
</div>
<div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12 mt-2">
<div class="row g-0 mt-2">
<app-read-more [text]="person.description || defaultSummaryText"></app-read-more>
@if (roles$ | async; as roles) {
<div class="mt-1">
<h5>Roles in Libraries</h5>
@for(role of roles; track role) {
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable" (click)="loadFilterByRole(role)">{{role | personRole}}</app-tag-badge>
}
</div>
}
</div>
</div>
</div>
</div>
@if (works$ | async; as works) {
<div class="row mt-2">
<app-carousel-reel [items]="works" [title]="t('known-for-title')" (sectionClick)="loadFilterByPerson()">
<ng-template #carouselItem let-item>
<app-card-item [entity]="item"
[imageUrl]="imageService.getSeriesCoverImage(item.id)"
[title]="item.name"
[suppressArchiveWarning]="true"
(clicked)="navigateToSeries(item)">
<ng-template #subtitle>
Hello
</ng-template>
</app-card-item>
</ng-template>
</app-carousel-reel>
</div>
}
<!-- Individual Works Carousel -->
@if (roles$ | async; as roles) {
@for(role of roles; track role) {
<div class="row mt-2">
<app-carousel-reel [items]="(chaptersByRole[role] | async)!" [title]="t('individual-role-title', {role: (role | personRole)})" (sectionClick)="loadFilterByRole(role)">
<ng-template #carouselItem let-item>
<app-chapter-card [chapter]="item" [libraryId]="item.libraryId" [libraryType]="item.libraryType" [seriesId]="item.seriesId"></app-chapter-card>
</ng-template>
</app-carousel-reel>
</div>
}
}
<!-- Collaborated In Carousel -->
<div class="row mt-2">
</div>
<!-- Might be Interested In... -->
<div class="row pt-3">
</div>
<!-- External Series including Author -->
@if (accountService.hasValidLicense$ | async) {
<div class="row mt-2">
</div>
}
}
</ng-container>
</div>

View file

@ -0,0 +1,4 @@
.main-container {
margin-top: 10px;
padding: 0 0 0 10px;
}

View file

@ -0,0 +1,209 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
ElementRef,
Inject,
inject,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from "@angular/router";
import {PersonService} from "../_services/person.service";
import {Observable, switchMap, tap} from "rxjs";
import {Person, PersonRole} from "../_models/metadata/person";
import {AsyncPipe, DOCUMENT, NgStyle} from "@angular/common";
import {ImageComponent} from "../shared/image/image.component";
import {ImageService} from "../_services/image.service";
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {SeriesCardComponent} from "../cards/series-card/series-card.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
import {Series} from "../_models/series";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {AccountService} from "../_services/account.service";
import {CardItemComponent} from "../cards/card-item/card-item.component";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {EditPersonModalComponent} from "./_modal/edit-person-modal/edit-person-modal.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {ThemeService} from "../_services/theme.service";
@Component({
selector: 'app-person-detail',
standalone: true,
imports: [
AsyncPipe,
ImageComponent,
SideNavCompanionBarComponent,
NgStyle,
ReadMoreComponent,
TagBadgeComponent,
PersonRolePipe,
CarouselReelComponent,
SeriesCardComponent,
CardItemComponent,
CardActionablesComponent,
TranslocoDirective,
ChapterCardComponent
],
templateUrl: './person-detail.component.html',
styleUrl: './person-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PersonDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly personService = inject(PersonService);
private readonly actionService = inject(ActionFactoryService);
private readonly modalService = inject(NgbModal);
protected readonly imageService = inject(ImageService);
protected readonly accountService = inject(AccountService);
private readonly themeService = inject(ThemeService);
protected readonly TagBadgeCursor = TagBadgeCursor;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
personName!: string;
person$: Observable<Person> | null = null;
person: Person | null = null;
roles$: Observable<PersonRole[]> | null = null;
roles: PersonRole[] | null = null;
works$: Observable<Series[]> | null = null;
defaultSummaryText = 'No information about this Person';
filter: SeriesFilterV2 | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {};
constructor(@Inject(DOCUMENT) private document: Document) {
this.route.paramMap.subscribe(_ => {
const personName = this.route.snapshot.paramMap.get('name');
if (personName === null || undefined) {
this.router.navigateByUrl('/home');
return;
}
this.personName = personName;
this.person$ = this.personService.get(this.personName).pipe(tap(p => {
this.person = p;
this.themeService.setColorScape(this.person.primaryColor || '', this.person.secondaryColor);
this.roles$ = this.personService.getRolesForPerson(this.personName).pipe(tap(roles => {
this.roles = roles;
this.filter = this.createFilter(roles);
for(let role of roles) {
this.chaptersByRole[role] = this.personService.getChaptersByRole(this.person!.id, role).pipe(takeUntilDestroyed(this.destroyRef));
}
this.cdRef.markForCheck();
}), takeUntilDestroyed(this.destroyRef));
this.works$ = this.personService.getSeriesMostKnownFor(this.person.id).pipe(
takeUntilDestroyed(this.destroyRef)
);
this.cdRef.markForCheck();
}), takeUntilDestroyed(this.destroyRef));
});
}
createFilter(roles: PersonRole[]) {
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
filter.combination = FilterCombination.Or;
filter.limitTo = 20;
// I might want to use roles$ to do all this
allPeople.forEach(f => {
filter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: f});
});
return filter;
}
loadFilterByPerson() {
const loadPage = (person: Person) => {
// Create a filter of all roles with OR
const params: any = {};
params['page'] = 1;
params['title'] = translate('person-detail.browse-person-title', {name: person.name});
const searchFilter = {...this.filter!};
searchFilter.limitTo = 0;
return this.filterUtilityService.applyFilterWithParams(['all-series'], searchFilter, params);
};
if (this.person) {
loadPage(this.person).subscribe();
} else {
this.person$?.pipe(switchMap((p: Person) => {
return loadPage(p);
})).subscribe();
}
}
loadFilterByRole(role: PersonRole) {
const personPipe = new PersonRolePipe();
// Create a filter of all roles with OR
const params: any = {};
params['page'] = 1;
params['title'] = translate('person-detail.browse-person-by-role-title', {name: this.person!.name, role: personPipe.transform(role)});
const searchFilter = this.filterUtilityService.createSeriesV2Filter();
searchFilter.limitTo = 0;
searchFilter.combination = FilterCombination.Or;
searchFilter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: personRoleForFilterField(role)});
this.filterUtilityService.applyFilterWithParams(['all-series'], searchFilter, params).subscribe();
}
navigateToSeries(series: Series) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
handleAction(action: ActionItem<Person>, person: Person) {
switch (action.action) {
case(Action.Edit):
const ref = this.modalService.open(EditPersonModalComponent, {scrollable: true, size: 'lg', fullscreen: 'md'});
ref.componentInstance.person = this.person;
ref.closed.subscribe(r => {
if (r.success) {
this.person = {...r.person};
this.cdRef.markForCheck();
}
});
break;
default:
break;
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.person);
}
}
}

View file

@ -122,7 +122,7 @@
[allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a>
<a routerLink="/person/{{item.name}}/" class="dark-exempt btn-icon">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>

View file

@ -26,6 +26,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";
export const DEBOUNCE_TIME = 100;
@ -360,24 +361,28 @@ export class DownloadService {
}
}
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson) {
if(this.utilityService.isSeries(entity)) {
return events.find(e => e.entityType === 'series' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;
}
if(this.utilityService.isVolume(entity)) {
return events.find(e => e.entityType === 'volume' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null;
}
if(this.utilityService.isChapter(entity)) {
return events.find(e => e.entityType === 'chapter' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null;
}
// Is PageBookmark[]
if(entity.hasOwnProperty('length')) {
return events.find(e => e.entityType === 'bookmark'
&& e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null;
}
return null;
}
}

View file

@ -109,7 +109,8 @@ export class ImageComponent implements OnChanges {
}
if (this.classes != '') {
this.renderer.addClass(this.imgElem.nativeElement, this.classes);
const classTokens = this.classes.split(' ');
classTokens.forEach(cls => this.renderer.addClass(this.imgElem.nativeElement, cls));
}
this.cdRef.markForCheck();
}

View file

@ -1,15 +1,25 @@
<div class="tagbadge cursor clickable" *ngIf="person !== undefined">
@if (person !== undefined) {
<div class="tagbadge cursor clickable">
<div class="d-flex flex-column">
<ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}"[imageUrl]="staff.imageUrl"></app-image>
</ng-container>
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center text-center mb-2" aria-hidden="true"></i>
</ng-template>
<div class="flex-grow-1 text-center">
<span class="mt-0 mb-0">
{{person.name}}
</span>
</div>
@if (HasCoverImage) {
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}"
classes="align-self-center text-center mb-2"
[imageUrl]="ImageUrl"
[errorImage]="imageService.noPersonImage">
</app-image>
} @else {
<i class="fas fa-user mx-auto" aria-hidden="true"></i>
}
<div class="flex-grow-1 text-center mt-2">
@if (isStaff) {
<span class="mt-1 mb-0">{{person.name}}</span>
} @else {
<a class="btn btn-icon p-0" routerLink="/person/{{person.name}}">{{person.name}}</a>
}
</div>
</div>
</div>
</div>
}

View file

@ -3,24 +3,38 @@ import { Person } from '../../_models/metadata/person';
import {CommonModule} from "@angular/common";
import {SeriesStaff} from "../../_models/series-detail/external-series-detail";
import {ImageComponent} from "../image/image.component";
import {ImageService} from "../../_services/image.service";
import {RouterLink} from "@angular/router";
@Component({
selector: 'app-person-badge',
standalone: true,
imports: [CommonModule, ImageComponent],
imports: [ImageComponent, RouterLink],
templateUrl: './person-badge.component.html',
styleUrls: ['./person-badge.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonBadgeComponent implements OnInit {
protected readonly imageService = inject(ImageService);
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) person!: Person | SeriesStaff;
@Input() isStaff = false;
private readonly cdRef = inject(ChangeDetectorRef);
staff!: SeriesStaff;
get HasCoverImage() {
return this.isStaff || (this.person as Person).coverImage;
}
get ImageUrl() {
if (this.isStaff && this.staff.imageUrl && !this.staff.imageUrl.endsWith('default.jpg')) {
return (this.person as SeriesStaff).imageUrl || '';
}
return this.imageService.getPersonImage((this.person as Person).id);
}
ngOnInit() {
this.staff = this.person as SeriesStaff;
this.cdRef.markForCheck();

View file

@ -56,6 +56,10 @@
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
}
@case (SideNavStreamType.BrowseAuthors) {
<app-side-nav-item icon="fa-star" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
}
@case (SideNavStreamType.SmartFilter) {
<app-side-nav-item icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
}

View file

@ -20,9 +20,4 @@ export class SidenavStreamListItemComponent {
@Output() hide: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
protected readonly SideNavStreamType = SideNavStreamType;
protected readonly baseUrl = inject(APP_BASE_HREF);
constructor() {
console.log('baseUrl', this.baseUrl);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -901,6 +901,7 @@
"collections": "Collections",
"reading-lists": "Reading Lists",
"bookmarks": "Bookmarks",
"browse-authors": "Browse Authors",
"filter-label": "{{common.filter}}",
"all-series": "All Series",
"clear": "{{common.clear}}",
@ -911,6 +912,19 @@
"customize": "{{settings.customize}}"
},
"browse-authors": {
"title": "Browse Authors & Writers",
"author-count": "{{num}} People",
"cover-image-description": "{{edit-series-modal.cover-image-description}}"
},
"person-detail": {
"known-for-title": "Known For",
"individual-role-title": "As a {{role}}",
"browse-person-title": "All Works of {{name}}",
"browse-person-by-role-title": "All Works of {{name}} as a {{role}}"
},
"library-settings-modal": {
"close": "{{common.close}}",
"edit-title": "Edit {{name}}",
@ -1985,6 +1999,24 @@
"cover-image-description": "{{edit-series-modal.cover-image-description}}"
},
"edit-person-modal": {
"title": "{{personName}} Details",
"general-tab": "{{edit-series-modal.general-tab}}",
"cover-image-tab": "{{edit-series-modal.cover-image-tab}}",
"loading": "{{common.loading}}",
"close": "{{common.close}}",
"name-label": "{{edit-series-modal.name-label}}",
"role-label": "Role",
"mal-id-label": "MAL Id",
"anilist-id-label": "AniList Id",
"hardcover-id-label": "Hardcover Id",
"asin-label": "ASIN",
"description-label": "Description",
"required-field": "{{validations.required-field}}",
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
"save": "{{common.save}}"
},
"day-breakdown": {
"title": "Day Breakdown",
"no-data": "No progress, get to reading",
@ -2197,7 +2229,8 @@
"collections": "{{side-nav.collections}}",
"reading-lists": "{{side-nav.reading-lists}}",
"bookmarks": "{{side-nav.bookmarks}}",
"all-series": "{{side-nav.all-series}}"
"all-series": "{{side-nav.all-series}}",
"browse-authors": "{{side-nav.browse-authors}}"
},
"filter-field-pipe": {
@ -2205,7 +2238,7 @@
"characters": "{{metadata-fields.characters-title}}",
"collection-tags": "Collection Tags",
"colorist": "Colorist",
"cover-artist": "{{person-role-pipe.cover-artist}}",
"cover-artist": "{{person-role-pipe.artist}}",
"editor": "Editor",
"formats": "Formats",
"genres": "{{metadata-fields.genres-title}}",
@ -2555,6 +2588,7 @@
"select-all": "Select All",
"deselect-all": "Deselect All",
"series-count": "{{num}} Series",
"author-count": "{{num}} Authors",
"item-count": "{{num}} Items",
"book-num": "Book",