Added the ability to have a richer title experience on all series page (#3825)
This commit is contained in:
parent
ef77474e33
commit
dadf9cbac8
6 changed files with 223 additions and 98 deletions
82
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal file
82
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import {HttpClient} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {tap} from 'rxjs/operators';
|
||||
import {of} from 'rxjs';
|
||||
import {map, of} from 'rxjs';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {Genre} from '../_models/metadata/genre';
|
||||
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 {FilterComparison} from '../_models/metadata/v2/filter-comparison';
|
||||
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 {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
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 {TextResonse} from "../_types/text-response";
|
||||
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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MetadataService {
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly collectionTagService = inject(CollectionTagService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
private validLanguages: Array<Language> = [];
|
||||
private ageRatingPipe = new AgeRatingPipe();
|
||||
private mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
|
|
@ -183,4 +194,66 @@ export class MetadataService {
|
|||
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}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ import {
|
|||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
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({
|
||||
|
|
@ -57,6 +61,7 @@ export class AllSeriesComponent implements OnInit {
|
|||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
protected readonly metadataService = inject(MetadataService);
|
||||
|
||||
title: string = translate('side-nav.all-series');
|
||||
series: Series[] = [];
|
||||
|
|
@ -68,7 +73,7 @@ export class AllSeriesComponent implements OnInit {
|
|||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActive: boolean = false;
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
|
||||
browseTitlePipe = new BrowseTitlePipe();
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
|
@ -126,8 +131,30 @@ export class AllSeriesComponent implements OnInit {
|
|||
|
||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||
this.filter = filter;
|
||||
|
||||
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter.name || 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!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
|
||||
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) {
|
||||
if (data.filterV2 === undefined) return;
|
||||
this.filter = data.filterV2;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@ import {
|
|||
} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
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 {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 {CollectionTagService} from 'src/app/_services/collection-tag.service';
|
||||
import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison';
|
||||
|
|
@ -277,63 +275,9 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||
|
||||
getDropdownObservable(): Observable<Select2Option[]> {
|
||||
const filterField = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
|
||||
switch (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([]);
|
||||
return this.metadataService.getOptionsForFilterField(filterField);
|
||||
}
|
||||
|
||||
getPersonOptions(role: PersonRole) {
|
||||
return this.metadataService.getAllPeopleByRole(role).pipe(map(people => people.map(person => {
|
||||
return {value: person.id, label: person.name}
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
handleFieldChange(val: string) {
|
||||
const inputVal = parseInt(val, 10) as FilterField;
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ export class MetadataFilterComponent implements OnInit {
|
|||
protected readonly translocoService = inject(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
|
||||
*/
|
||||
|
|
@ -88,10 +93,7 @@ export class MetadataFilterComponent implements OnInit {
|
|||
fullyLoaded: boolean = false;
|
||||
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 {
|
||||
|
|
@ -113,38 +115,6 @@ export class MetadataFilterComponent implements OnInit {
|
|||
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() {
|
||||
this.filterOpen.emit(false);
|
||||
|
|
|
|||
|
|
@ -2590,6 +2590,31 @@
|
|||
"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": {
|
||||
"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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue