Added the ability to have a richer title experience on all series page (#3825)

This commit is contained in:
Joseph Milazzo 2025-06-06 15:30:47 -05:00
parent ef77474e33
commit dadf9cbac8
6 changed files with 223 additions and 98 deletions

View file

@ -0,0 +1,82 @@
import {Pipe, PipeTransform} from '@angular/core';
import {FilterField} from "../_models/metadata/v2/filter-field";
import {translate} from "@jsverse/transloco";
// export type BrowseTitleFields = FilterField.Genres | FilterField.Tags | FilterField.Editor | FilterField.Inker |
// FilterField.
/**
* Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page
* Example: Genre & "Action" -> Browse Action
* Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works
*/
@Pipe({
name: 'browseTitle'
})
export class BrowseTitlePipe implements PipeTransform {
transform(field: FilterField, value: string): string {
switch (field) {
case FilterField.PublicationStatus:
return translate('browse-title-pipe.publication-status', {value});
case FilterField.AgeRating:
return translate('browse-title-pipe.age-rating', {value});
case FilterField.UserRating:
return translate('browse-title-pipe.user-rating', {value});
case FilterField.Tags:
return translate('browse-title-pipe.tag', {value});
case FilterField.Translators:
return translate('browse-title-pipe.translator', {value});
case FilterField.Characters:
return translate('browse-title-pipe.character', {value});
case FilterField.Publisher:
return translate('browse-title-pipe.publisher', {value});
case FilterField.Editor:
return translate('browse-title-pipe.editor', {value});
case FilterField.CoverArtist:
return translate('browse-title-pipe.artist', {value});
case FilterField.Letterer:
return translate('browse-title-pipe.letterer', {value});
case FilterField.Colorist:
return translate('browse-title-pipe.colorist', {value});
case FilterField.Inker:
return translate('browse-title-pipe.inker', {value});
case FilterField.Penciller:
return translate('browse-title-pipe.penciller', {value});
case FilterField.Writers:
return translate('browse-title-pipe.writer', {value});
case FilterField.Genres:
return translate('browse-title-pipe.genre', {value});
case FilterField.Libraries:
return translate('browse-title-pipe.library', {value});
case FilterField.Formats:
return translate('browse-title-pipe.format', {value});
case FilterField.ReleaseYear:
return translate('browse-title-pipe.release-year', {value});
case FilterField.Imprint:
return translate('browse-title-pipe.imprint', {value});
case FilterField.Team:
return translate('browse-title-pipe.team', {value});
case FilterField.Location:
return translate('browse-title-pipe.location', {value});
// These have no natural links in the app to demand a richer title experience
case FilterField.Languages:
case FilterField.CollectionTags:
case FilterField.ReadProgress:
case FilterField.ReadTime:
case FilterField.Path:
case FilterField.FilePath:
case FilterField.WantToRead:
case FilterField.ReadingDate:
case FilterField.AverageRating:
case FilterField.ReadLast:
case FilterField.Summary:
case FilterField.SeriesName:
default:
return '';
}
}
}

View file

@ -1,7 +1,7 @@
import {HttpClient} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core'; import {inject, Injectable} from '@angular/core';
import {tap} from 'rxjs/operators'; import {tap} from 'rxjs/operators';
import {of} from 'rxjs'; import {map, of} from 'rxjs';
import {environment} from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import {Genre} from '../_models/metadata/genre'; import {Genre} from '../_models/metadata/genre';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
@ -11,7 +11,7 @@ import {Person, PersonRole} from '../_models/metadata/person';
import {Tag} from '../_models/tag'; import {Tag} from '../_models/tag';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
import {FilterField} from '../_models/metadata/v2/filter-field'; import {FilterField} from '../_models/metadata/v2/filter-field';
import {SortField} from "../_models/metadata/series-filter"; import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination"; import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {FilterStatement} from "../_models/metadata/v2/filter-statement";
@ -20,14 +20,25 @@ import {LibraryType} from "../_models/library/library";
import {IHasCast} from "../_models/common/i-has-cast"; import {IHasCast} from "../_models/common/i-has-cast";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {QueryContext} from "../_models/metadata/v2/query-context"; import {QueryContext} from "../_models/metadata/v2/query-context";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {TranslocoService} from "@jsverse/transloco";
import {LibraryService} from './library.service';
import {CollectionTagService} from "./collection-tag.service";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class MetadataService { export class MetadataService {
private readonly translocoService = inject(TranslocoService);
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = []; private validLanguages: Array<Language> = [];
private ageRatingPipe = new AgeRatingPipe();
private mangaFormatPipe = new MangaFormatPipe(this.translocoService);
constructor(private httpClient: HttpClient) { } constructor(private httpClient: HttpClient) { }
@ -183,4 +194,66 @@ export class MetadataService {
break; break;
} }
} }
/**
* Used to get the underlying Options (for Metadata Filter Dropdowns)
* @param filterField
*/
getOptionsForFilterField(filterField: FilterField) {
switch (filterField) {
case FilterField.PublicationStatus:
return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
return {value: pub.value, label: pub.title}
})));
case FilterField.AgeRating:
return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)}
})));
case FilterField.Genres:
return this.getAllGenres().pipe(map(genres => genres.map(genre => {
return {value: genre.id, label: genre.title}
})));
case FilterField.Languages:
return this.getAllLanguages().pipe(map(statuses => statuses.map(status => {
return {value: status.isoCode, label: status.title + ` (${status.isoCode})`}
})));
case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, label: this.mangaFormatPipe.transform(status.value)}
})));
case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, label: lib.name}
})));
case FilterField.Tags:
return this.getAllTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.CollectionTags:
return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist);
case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist);
case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor);
case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker);
case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer);
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Team: return this.getPersonOptions(PersonRole.Team);
case FilterField.Location: return this.getPersonOptions(PersonRole.Location);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
}
return of([]);
}
private getPersonOptions(role: PersonRole) {
return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => {
return {value: person.id, label: person.name}
})));
}
} }

View file

@ -33,6 +33,10 @@ import {
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {BrowseTitlePipe} from "../../../_pipes/browse-title.pipe";
import {MetadataService} from "../../../_services/metadata.service";
import {Observable} from "rxjs";
@Component({ @Component({
@ -57,6 +61,7 @@ export class AllSeriesComponent implements OnInit {
private readonly jumpbarService = inject(JumpbarService); private readonly jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
protected readonly bulkSelectionService = inject(BulkSelectionService); protected readonly bulkSelectionService = inject(BulkSelectionService);
protected readonly metadataService = inject(MetadataService);
title: string = translate('side-nav.all-series'); title: string = translate('side-nav.all-series');
series: Series[] = []; series: Series[] = [];
@ -68,7 +73,7 @@ export class AllSeriesComponent implements OnInit {
filterActiveCheck!: SeriesFilterV2; filterActiveCheck!: SeriesFilterV2;
filterActive: boolean = false; filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = []; jumpbarKeys: Array<JumpKey> = [];
browseTitlePipe = new BrowseTitlePipe();
bulkActionCallback = (action: ActionItem<any>, data: any) => { bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -126,8 +131,30 @@ export class AllSeriesComponent implements OnInit {
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter; this.filter = filter;
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter.name || this.title; this.title = this.route.snapshot.queryParamMap.get('title') || this.filter.name || this.title;
this.titleService.setTitle('Kavita - ' + this.title); this.titleService.setTitle('Kavita - ' + this.title);
// To provide a richer experience, when we are browsing just a Genre/Tag/etc, we regenerate the title (if not explicitly passed) to "Browse {GenreName}"
if (this.shouldRewriteTitle()) {
const field = this.filter.statements[0].field;
// This api returns value as string and number, it will complain without the casting
(this.metadataService.getOptionsForFilterField(field) as Observable<any>).subscribe((opts: any[]) => {
const matchingOpts = opts.filter(m => `${m.value}` === `${this.filter!.statements[0].value}`);
if (matchingOpts.length === 0) return;
const value = matchingOpts[0].label;
const newTitle = this.browseTitlePipe.transform(field, value);
if (newTitle !== '') {
this.title = newTitle;
this.titleService.setTitle('Kavita - ' + this.title);
this.cdRef.markForCheck();
}
});
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.presetsV2 = this.filter; this.filterSettings.presetsV2 = this.filter;
@ -143,6 +170,10 @@ export class AllSeriesComponent implements OnInit {
}); });
} }
shouldRewriteTitle() {
return this.title === translate('side-nav.all-series') && this.filter && this.filter.statements.length === 1 && this.filter.statements[0].comparison === FilterComparison.Equal
}
updateFilter(data: FilterEvent) { updateFilter(data: FilterEvent) {
if (data.filterV2 === undefined) return; if (data.filterV2 === undefined) return;
this.filter = data.filterV2; this.filter = data.filterV2;

View file

@ -11,10 +11,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; import {FilterStatement} from '../../../_models/metadata/v2/filter-statement';
import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs'; import {BehaviorSubject, distinctUntilChanged, filter, Observable, of, startWith, switchMap, tap} from 'rxjs';
import {MetadataService} from 'src/app/_services/metadata.service'; import {MetadataService} from 'src/app/_services/metadata.service';
import {mangaFormatFilters} from 'src/app/_models/metadata/series-filter';
import {PersonRole} from 'src/app/_models/metadata/person';
import {LibraryService} from 'src/app/_services/library.service'; import {LibraryService} from 'src/app/_services/library.service';
import {CollectionTagService} from 'src/app/_services/collection-tag.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service';
import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison';
@ -277,62 +275,8 @@ export class MetadataFilterRowComponent implements OnInit {
getDropdownObservable(): Observable<Select2Option[]> { getDropdownObservable(): Observable<Select2Option[]> {
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField; const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
switch (filterField) { return this.metadataService.getOptionsForFilterField(filterField);
case FilterField.PublicationStatus:
return this.metadataService.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
return {value: pub.value, label: pub.title}
})));
case FilterField.AgeRating:
return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)}
})));
case FilterField.Genres:
return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => {
return {value: genre.id, label: genre.title}
})));
case FilterField.Languages:
return this.metadataService.getAllLanguages().pipe(map(statuses => statuses.map(status => {
return {value: status.isoCode, label: status.title + ` (${status.isoCode})`}
})));
case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, label: this.mangaFormatPipe.transform(status.value)}
})));
case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, label: lib.name}
})));
case FilterField.Tags:
return this.metadataService.getAllTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.CollectionTags:
return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist);
case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist);
case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor);
case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker);
case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer);
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Team: return this.getPersonOptions(PersonRole.Team);
case FilterField.Location: return this.getPersonOptions(PersonRole.Location);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
} }
return of([]);
}
getPersonOptions(role: PersonRole) {
return this.metadataService.getAllPeopleByRole(role).pipe(map(people => people.map(person => {
return {value: person.id, label: person.name}
})));
}
handleFieldChange(val: string) { handleFieldChange(val: string) {
const inputVal = parseInt(val, 10) as FilterField; const inputVal = parseInt(val, 10) as FilterField;

View file

@ -61,6 +61,11 @@ export class MetadataFilterComponent implements OnInit {
protected readonly translocoService = inject(TranslocoService); protected readonly translocoService = inject(TranslocoService);
private readonly sortFieldPipe = new SortFieldPipe(this.translocoService); private readonly sortFieldPipe = new SortFieldPipe(this.translocoService);
protected readonly allSortFields = allSortFields.map(f => {
return {title: this.sortFieldPipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
protected readonly allFilterFields = allFields;
/** /**
* This toggles the opening/collapsing of the metadata filter code * This toggles the opening/collapsing of the metadata filter code
*/ */
@ -88,10 +93,7 @@ export class MetadataFilterComponent implements OnInit {
fullyLoaded: boolean = false; fullyLoaded: boolean = false;
filterV2: SeriesFilterV2 | undefined; filterV2: SeriesFilterV2 | undefined;
protected readonly allSortFields = allSortFields.map(f => {
return {title: this.sortFieldPipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
protected readonly allFilterFields = allFields;
ngOnInit(): void { ngOnInit(): void {
@ -113,38 +115,6 @@ export class MetadataFilterComponent implements OnInit {
this.loadFromPresetsAndSetup(); this.loadFromPresetsAndSetup();
} }
// loadSavedFilter(event: Select2UpdateEvent<any>) {
// // Load the filter from the backend and update the screen
// if (event.value === undefined || typeof(event.value) === 'string') return;
// const smartFilter = event.value as SmartFilter;
// this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter);
// this.cdRef.markForCheck();
// console.log('update event: ', event);
// }
//
// createFilterValue(event: Select2AutoCreateEvent<any>) {
// // Create a new name and filter
// if (!this.filterV2) return;
// this.filterV2.name = event.value;
// this.filterService.saveFilter(this.filterV2).subscribe(() => {
//
// const item = {
// value: {
// filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!),
// name: event.value,
// } as SmartFilter,
// label: event.value
// };
// this.smartFilters.push(item);
// this.sortGroup.get('name')?.setValue(item);
// this.cdRef.markForCheck();
// this.toastr.success(translate('toasts.smart-filter-updated'));
// this.apply();
// });
//
// console.log('create event: ', event);
// }
close() { close() {
this.filterOpen.emit(false); this.filterOpen.emit(false);

View file

@ -2590,6 +2590,31 @@
"prompt": "Question" "prompt": "Question"
}, },
"browse-title-pipe": {
"publication-status": "{{value}} works",
"age-rating": "Rated {{value}}",
"user-rating": "Rated {{value}} stars",
"tag": "Has Tag {{value}}",
"translator": "Translated by {{value}}",
"character": "Has character {{value}}",
"publisher": "Published by {{value}}",
"editor": "Edited by {{value}}",
"artist": "Drawn by {{value}}",
"letterer": "Lettered by {{value}}",
"colorist": "Colored by {{value}}",
"inker": "Inked by {{value}}",
"penciller": "Pencilled by {{value}}",
"writer": "Written by {{value}}",
"genre": "Has Genre {{value}}",
"library": "Within {{value}} library",
"format": "Format of {{value}}",
"release-year": "Released in {{value}}",
"imprint": "Imprint of {{value}}",
"team": "Team {{value}}",
"location": "In {{value}} location"
},
"toasts": { "toasts": {
"regen-cover": "A job has been enqueued to regenerate the cover image", "regen-cover": "A job has been enqueued to regenerate the cover image",
"no-pages": "There are no pages. Kavita was not able to read this archive.", "no-pages": "There are no pages. Kavita was not able to read this archive.",