From dadf9cbac862fe6f44e88e8b1684c2f58935b34e Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 6 Jun 2025 15:30:47 -0500 Subject: [PATCH] Added the ability to have a richer title experience on all series page (#3825) --- UI/Web/src/app/_pipes/browse-title.pipe.ts | 82 +++++++++++++++++++ UI/Web/src/app/_services/metadata.service.ts | 79 +++++++++++++++++- .../all-series/all-series.component.ts | 33 +++++++- .../metadata-filter-row.component.ts | 60 +------------- .../metadata-filter.component.ts | 42 ++-------- UI/Web/src/assets/langs/en.json | 25 ++++++ 6 files changed, 223 insertions(+), 98 deletions(-) create mode 100644 UI/Web/src/app/_pipes/browse-title.pipe.ts diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts new file mode 100644 index 000000000..a6256f065 --- /dev/null +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -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 ''; + } + } + +} diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 314e5c37b..aa860dd81 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -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 = []; + 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} + }))); + } } diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index d0636ab5c..453ff1c31 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -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 = []; - + browseTitlePipe = new BrowseTitlePipe(); bulkActionCallback = (action: ActionItem, 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).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; diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 34a1b7db8..b0dbf0608 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -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 { 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; diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index c65bb5c16..b35b39775 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -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) { - // // 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) { - // // 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); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 027ea1cf0..4e6af2974 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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.",