Browse by Genre/Tag/Person with new metadata system for People (#3835)

Co-authored-by: Stepan Goremykin <s.goremykin@proton.me>
Co-authored-by: goremykin <goremukin@gmail.com>
Co-authored-by: Christopher <39032787+MrRobotjs@users.noreply.github.com>
Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-06-14 12:14:04 -05:00 committed by GitHub
parent 00c4712fc3
commit c52ed1f65d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 6612 additions and 958 deletions

View file

@ -0,0 +1,30 @@
.tag-card {
background-color: var(--bs-card-color, #2c2c2c);
padding: 1rem;
border-radius: 12px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform 0.2s ease, background 0.3s ease;
cursor: pointer;
}
.tag-card:hover {
background-color: #3a3a3a;
//transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this
}
.tag-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
max-height: 8rem;
height: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-meta {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
color: var(--text-muted-color, #bbb);
}

View file

@ -1,6 +1,8 @@
import {MatchStateOption} from "./match-state-option";
import {LibraryType} from "../library/library";
export interface ManageMatchFilter {
matchStateOption: MatchStateOption;
libraryType: LibraryType | -1;
searchTerm: string;
}

View file

@ -13,6 +13,8 @@ export enum LibraryType {
}
export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images];
export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic];
export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel];
export interface Library {
id: number;

View file

@ -0,0 +1,6 @@
import {Genre} from "../genre";
export interface BrowseGenre extends Genre {
seriesCount: number;
chapterCount: number;
}

View file

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

View file

@ -0,0 +1,6 @@
import {Tag} from "../../tag";
export interface BrowseTag extends Tag {
seriesCount: number;
chapterCount: number;
}

View file

@ -4,7 +4,10 @@ export interface Language {
}
export interface KavitaLocale {
fileName: string; // isoCode aka what maps to the file on disk and what transloco loads
/**
* isoCode aka what maps to the file on disk and what transloco loads
*/
fileName: string;
renderName: string;
translationCompletion: number;
isRtL: boolean;

View file

@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover";
export enum PersonRole {
Other = 1,
Artist = 2,
Writer = 3,
Penciller = 4,
Inker = 5,
@ -32,3 +31,22 @@ export interface Person extends IHasCover {
primaryColor: string;
secondaryColor: string;
}
/**
* Excludes Other as it's not in use
*/
export const allPeopleRoles = [
PersonRole.Writer,
PersonRole.Penciller,
PersonRole.Inker,
PersonRole.Colorist,
PersonRole.Letterer,
PersonRole.CoverArtist,
PersonRole.Editor,
PersonRole.Publisher,
PersonRole.Character,
PersonRole.Translator,
PersonRole.Imprint,
PersonRole.Team,
PersonRole.Location
]

View file

@ -1,5 +1,5 @@
import {MangaFormat} from "../manga-format";
import {SeriesFilterV2} from "./v2/series-filter-v2";
import {FilterV2} from "./v2/filter-v2";
export interface FilterItem<T> {
title: string;
@ -7,10 +7,6 @@ export interface FilterItem<T> {
selected: boolean;
}
export interface SortOptions {
sortField: SortField;
isAscending: boolean;
}
export enum SortField {
SortName = 1,
@ -27,7 +23,7 @@ export enum SortField {
Random = 9
}
export const allSortFields = Object.keys(SortField)
export const allSeriesSortFields = Object.keys(SortField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as SortField[];
@ -54,8 +50,8 @@ export const mangaFormatFilters = [
}
];
export interface FilterEvent {
filterV2: SeriesFilterV2;
export interface FilterEvent<TFilter extends number = number, TSort extends number = number> {
filterV2: FilterV2<TFilter, TSort>;
isFirst: boolean;
}

View file

@ -0,0 +1,8 @@
import {PersonRole} from "../person";
import {PersonSortOptions} from "./sort-options";
export interface BrowsePersonFilter {
roles: Array<PersonRole>;
query?: string;
sortOptions?: PersonSortOptions;
}

View file

@ -48,7 +48,7 @@ const enumArray = Object.keys(FilterField)
enumArray.sort((a, b) => a.value.localeCompare(b.value));
export const allFields = enumArray
export const allSeriesFilterFields = enumArray
.map(key => parseInt(key.key, 10))as FilterField[];
export const allPeople = [
@ -66,7 +66,6 @@ export const allPeople = [
export const personRoleForFilterField = (role: PersonRole) => {
switch (role) {
case PersonRole.Artist: return FilterField.CoverArtist;
case PersonRole.Character: return FilterField.Characters;
case PersonRole.Colorist: return FilterField.Colorist;
case PersonRole.CoverArtist: return FilterField.CoverArtist;

View file

@ -1,8 +1,7 @@
import { FilterComparison } from "./filter-comparison";
import { FilterField } from "./filter-field";
import {FilterComparison} from "./filter-comparison";
export interface FilterStatement {
export interface FilterStatement<T extends number = number> {
comparison: FilterComparison;
field: FilterField;
field: T;
value: string;
}
}

View file

@ -0,0 +1,11 @@
import {FilterStatement} from "./filter-statement";
import {FilterCombination} from "./filter-combination";
import {SortOptions} from "./sort-options";
export interface FilterV2<TFilter extends number = number, TSort extends number = number> {
name?: string;
statements: Array<FilterStatement<TFilter>>;
combination: FilterCombination;
sortOptions?: SortOptions<TSort>;
limitTo: number;
}

View file

@ -0,0 +1,12 @@
export enum PersonFilterField {
Role = 1,
Name = 2,
SeriesCount = 3,
ChapterCount = 4,
}
export const allPersonFilterFields = Object.keys(PersonFilterField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as PersonFilterField[];

View file

@ -0,0 +1,9 @@
export enum PersonSortField {
Name = 1,
SeriesCount = 2,
ChapterCount = 3
}
export const allPersonSortFields = Object.keys(PersonSortField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as PersonSortField[];

View file

@ -1,11 +0,0 @@
import { SortOptions } from "../series-filter";
import {FilterStatement} from "./filter-statement";
import {FilterCombination} from "./filter-combination";
export interface SeriesFilterV2 {
name?: string;
statements: Array<FilterStatement>;
combination: FilterCombination;
sortOptions?: SortOptions;
limitTo: number;
}

View file

@ -0,0 +1,17 @@
import {PersonSortField} from "./person-sort-field";
/**
* Series-based Sort options
*/
export interface SortOptions<TSort extends number = number> {
sortField: TSort;
isAscending: boolean;
}
/**
* Person-based Sort Options
*/
export interface PersonSortOptions {
sortField: PersonSortField;
isAscending: boolean;
}

View file

@ -12,6 +12,7 @@ import {PdfLayoutMode} from "./pdf-layout-mode";
import {PdfSpreadMode} from "./pdf-spread-mode";
import {Series} from "../series";
import {Library} from "../library/library";
import {UserBreakpoint} from "../../shared/_services/utility.service";
export enum ReadingProfileKind {
Default = 0,
@ -39,6 +40,7 @@ export interface ReadingProfile {
swipeToPaginate: boolean;
allowAutomaticWebtoonReaderDetection: boolean;
widthOverride?: number;
disableWidthOverride: UserBreakpoint;
// Book Reader
bookReaderMargin: number;
@ -75,3 +77,4 @@ export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multi
export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}];
export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}];
export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}];
export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop]

View file

@ -20,5 +20,6 @@ export enum WikiLink {
UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native',
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker',
OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients',
Guides = 'https://wiki.kavitareader.com/guides'
Guides = 'https://wiki.kavitareader.com/guides',
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
}

View file

@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
import {translate} from "@jsverse/transloco";
import {UserBreakpoint} from "../shared/_services/utility.service";
@Pipe({
name: 'breakpoint'
})
export class BreakpointPipe implements PipeTransform {
transform(value: UserBreakpoint): string {
const v = parseInt(value + '', 10) as UserBreakpoint;
switch (v) {
case UserBreakpoint.Never:
return translate('breakpoint-pipe.never');
case UserBreakpoint.Mobile:
return translate('breakpoint-pipe.mobile');
case UserBreakpoint.Tablet:
return translate('breakpoint-pipe.tablet');
case UserBreakpoint.Desktop:
return translate('breakpoint-pipe.desktop');
}
throw new Error("unknown breakpoint value: " + value);
}
}

View file

@ -0,0 +1,78 @@
import {Pipe, PipeTransform} from '@angular/core';
import {FilterField} from "../_models/metadata/v2/filter-field";
import {translate} from "@jsverse/transloco";
/**
* 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

@ -0,0 +1,108 @@
import {Pipe, PipeTransform} from '@angular/core';
import {FilterField} from "../_models/metadata/v2/filter-field";
import {translate} from "@jsverse/transloco";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
@Pipe({
name: 'genericFilterField'
})
export class GenericFilterFieldPipe implements PipeTransform {
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
switch (entityType) {
case "series":
return this.translateFilterField(value as FilterField);
case "person":
return this.translatePersonFilterField(value as PersonFilterField);
}
}
private translatePersonFilterField(value: PersonFilterField) {
switch (value) {
case PersonFilterField.Role:
return translate('generic-filter-field-pipe.person-role');
case PersonFilterField.Name:
return translate('generic-filter-field-pipe.person-name');
case PersonFilterField.SeriesCount:
return translate('generic-filter-field-pipe.person-series-count');
case PersonFilterField.ChapterCount:
return translate('generic-filter-field-pipe.person-chapter-count');
}
}
private translateFilterField(value: FilterField) {
switch (value) {
case FilterField.AgeRating:
return translate('filter-field-pipe.age-rating');
case FilterField.Characters:
return translate('filter-field-pipe.characters');
case FilterField.CollectionTags:
return translate('filter-field-pipe.collection-tags');
case FilterField.Colorist:
return translate('filter-field-pipe.colorist');
case FilterField.CoverArtist:
return translate('filter-field-pipe.cover-artist');
case FilterField.Editor:
return translate('filter-field-pipe.editor');
case FilterField.Formats:
return translate('filter-field-pipe.formats');
case FilterField.Genres:
return translate('filter-field-pipe.genres');
case FilterField.Inker:
return translate('filter-field-pipe.inker');
case FilterField.Imprint:
return translate('filter-field-pipe.imprint');
case FilterField.Team:
return translate('filter-field-pipe.team');
case FilterField.Location:
return translate('filter-field-pipe.location');
case FilterField.Languages:
return translate('filter-field-pipe.languages');
case FilterField.Libraries:
return translate('filter-field-pipe.libraries');
case FilterField.Letterer:
return translate('filter-field-pipe.letterer');
case FilterField.PublicationStatus:
return translate('filter-field-pipe.publication-status');
case FilterField.Penciller:
return translate('filter-field-pipe.penciller');
case FilterField.Publisher:
return translate('filter-field-pipe.publisher');
case FilterField.ReadProgress:
return translate('filter-field-pipe.read-progress');
case FilterField.ReadTime:
return translate('filter-field-pipe.read-time');
case FilterField.ReleaseYear:
return translate('filter-field-pipe.release-year');
case FilterField.SeriesName:
return translate('filter-field-pipe.series-name');
case FilterField.Summary:
return translate('filter-field-pipe.summary');
case FilterField.Tags:
return translate('filter-field-pipe.tags');
case FilterField.Translators:
return translate('filter-field-pipe.translators');
case FilterField.UserRating:
return translate('filter-field-pipe.user-rating');
case FilterField.Writers:
return translate('filter-field-pipe.writers');
case FilterField.Path:
return translate('filter-field-pipe.path');
case FilterField.FilePath:
return translate('filter-field-pipe.file-path');
case FilterField.WantToRead:
return translate('filter-field-pipe.want-to-read');
case FilterField.ReadingDate:
return translate('filter-field-pipe.read-date');
case FilterField.ReadLast:
return translate('filter-field-pipe.read-last');
case FilterField.AverageRating:
return translate('filter-field-pipe.average-rating');
default:
throw new Error(`Invalid FilterField value: ${value}`);
}
}
}

View file

@ -1,6 +1,6 @@
import {inject, Pipe, PipeTransform} from '@angular/core';
import { PersonRole } from '../_models/metadata/person';
import {translate, TranslocoService} from "@jsverse/transloco";
import {Pipe, PipeTransform} from '@angular/core';
import {PersonRole} from '../_models/metadata/person';
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'personRole',
@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform {
transform(value: PersonRole): string {
switch (value) {
case PersonRole.Artist:
return translate('person-role-pipe.artist');
case PersonRole.Character:
return translate('person-role-pipe.character');
case PersonRole.Colorist:

View file

@ -1,6 +1,8 @@
import { Pipe, PipeTransform } from '@angular/core';
import {Pipe, PipeTransform} from '@angular/core';
import {SortField} from "../_models/metadata/series-filter";
import {TranslocoService} from "@jsverse/transloco";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
@Pipe({
name: 'sortField',
@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform {
constructor(private translocoService: TranslocoService) {
}
transform(value: SortField): string {
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
switch (entityType) {
case 'series':
return this.seriesSortFields(value as SortField);
case 'person':
return this.personSortFields(value as PersonSortField);
}
}
private personSortFields(value: PersonSortField) {
switch (value) {
case PersonSortField.Name:
return this.translocoService.translate('sort-field-pipe.person-name');
case PersonSortField.SeriesCount:
return this.translocoService.translate('sort-field-pipe.person-series-count');
case PersonSortField.ChapterCount:
return this.translocoService.translate('sort-field-pipe.person-chapter-count');
}
}
private seriesSortFields(value: SortField) {
switch (value) {
case SortField.SortName:
return this.translocoService.translate('sort-field-pipe.sort-name');
@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform {
case SortField.Random:
return this.translocoService.translate('sort-field-pipe.random');
}
}
}

View file

@ -0,0 +1,22 @@
import {Injectable} from "@angular/core";
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router";
import {Observable, of} from "rxjs";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
/**
* Checks the url for a filter and resolves one if applicable, otherwise returns null.
* It is up to the consumer to cast appropriately.
*/
@Injectable({
providedIn: 'root'
})
export class UrlFilterResolver implements Resolve<any> {
constructor(private filterUtilitiesService: FilterUtilitiesService) {}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FilterV2 | null> {
if (!state.url.includes('?')) return of(null);
return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]);
}
}

View file

@ -1,7 +1,13 @@
import { Routes } from "@angular/router";
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
import {Routes} from "@angular/router";
import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component";
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
{path: '', component: AllSeriesComponent, pathMatch: 'full'},
{path: '', component: AllSeriesComponent, pathMatch: 'full',
runGuardsAndResolvers: 'always',
resolve: {
filter: UrlFilterResolver
}
},
];

View file

@ -1,6 +1,12 @@
import { Routes } from "@angular/router";
import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component";
import {Routes} from "@angular/router";
import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component";
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
{path: '', component: BookmarksComponent, pathMatch: 'full'},
{path: '', component: BookmarksComponent, pathMatch: 'full',
resolve: {
filter: UrlFilterResolver
},
runGuardsAndResolvers: 'always',
},
];

View file

@ -1,8 +0,0 @@
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,24 @@
import {Routes} from "@angular/router";
import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component";
import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component";
import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component";
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
// Legacy route
{path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full',
resolve: {
filter: UrlFilterResolver
},
runGuardsAndResolvers: 'always',
},
{path: 'people', component: BrowsePeopleComponent, pathMatch: 'full',
resolve: {
filter: UrlFilterResolver
},
runGuardsAndResolvers: 'always',
},
{path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'},
{path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'},
];

View file

@ -1,9 +1,15 @@
import { Routes } from '@angular/router';
import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component';
import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component';
import {Routes} from '@angular/router';
import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component';
import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component';
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
{path: '', component: AllCollectionsComponent, pathMatch: 'full'},
{path: ':id', component: CollectionDetailComponent},
{path: ':id', component: CollectionDetailComponent,
resolve: {
filter: UrlFilterResolver
},
runGuardsAndResolvers: 'always',
},
];

View file

@ -1,7 +1,8 @@
import { Routes } from '@angular/router';
import { AuthGuard } from '../_guards/auth.guard';
import { LibraryAccessGuard } from '../_guards/library-access.guard';
import { LibraryDetailComponent } from '../library-detail/library-detail.component';
import {Routes} from '@angular/router';
import {AuthGuard} from '../_guards/auth.guard';
import {LibraryAccessGuard} from '../_guards/library-access.guard';
import {LibraryDetailComponent} from '../library-detail/library-detail.component';
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
@ -9,12 +10,18 @@ export const routes: Routes = [
path: ':libraryId',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard, LibraryAccessGuard],
component: LibraryDetailComponent
component: LibraryDetailComponent,
resolve: {
filter: UrlFilterResolver
},
},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard, LibraryAccessGuard],
component: LibraryDetailComponent
}
component: LibraryDetailComponent,
resolve: {
filter: UrlFilterResolver
},
},
];

View file

@ -1,6 +1,10 @@
import { Routes } from '@angular/router';
import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component';
import {Routes} from '@angular/router';
import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component';
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
export const routes: Routes = [
{path: '', component: WantToReadComponent, pathMatch: 'full'},
{path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: {
filter: UrlFilterResolver
}
},
];

View file

@ -132,6 +132,33 @@ export class AccountService {
return roles.some(role => user.roles.includes(role));
}
/**
* If User or Admin, will return false
* @param user
* @param restrictedRoles
*/
hasAnyRestrictedRole(user: User, restrictedRoles: Array<Role> = []) {
if (!user || !user.roles) {
return true;
}
if (restrictedRoles.length === 0) {
return false;
}
// If the user is an admin, they have the role
if (this.hasAdminRole(user)) {
return false;
}
if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) {
return true;
}
return false;
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);
}

View file

@ -1,8 +1,7 @@
import { Injectable } from '@angular/core';
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {Injectable} from '@angular/core';
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {environment} from "../../environments/environment";
import { HttpClient } from "@angular/common/http";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {HttpClient} from "@angular/common/http";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
@Injectable({
@ -13,7 +12,7 @@ export class FilterService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
saveFilter(filter: SeriesFilterV2) {
saveFilter(filter: FilterV2<number>) {
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
}
getAllFilters() {
@ -26,5 +25,4 @@ export class FilterService {
renameSmartFilter(filter: SmartFilter) {
return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {});
}
}

View file

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { JumpKey } from '../_models/jumpbar/jump-key';
import {Injectable} from '@angular/core';
import {JumpKey} from '../_models/jumpbar/jump-key';
const keySize = 25; // Height of the JumpBar button
@ -105,14 +105,18 @@ export class JumpbarService {
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {};
data.forEach(obj => {
let ch = keySelector(obj).charAt(0).toUpperCase();
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
ch = '#';
try {
let ch = keySelector(obj).charAt(0).toUpperCase();
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
ch = '#';
}
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
} catch (e) {
console.error('Failed to calculate jump key for ', obj, e);
}
if (!keys.hasOwnProperty(ch)) {
keys[ch] = 0;
}
keys[ch] += 1;
});
return Object.keys(keys).map(k => {
k = k.toUpperCase();

View file

@ -1,33 +1,54 @@
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http';
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';
import {Language} from '../_models/metadata/language';
import {PublicationStatusDto} from '../_models/metadata/publication-status-dto';
import {Person, PersonRole} from '../_models/metadata/person';
import {allPeopleRoles, 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 {FilterV2} from "../_models/metadata/v2/filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
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";
import {PaginatedResult} from "../_models/pagination";
import {UtilityService} from "../shared/_services/utility.service";
import {BrowseGenre} from "../_models/metadata/browse/browse-genre";
import {BrowseTag} from "../_models/metadata/browse/browse-tag";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
@Injectable({
providedIn: 'root'
})
export class MetadataService {
private readonly translocoService = inject(TranslocoService);
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly utilityService = inject(UtilityService);
baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = [];
private ageRatingPipe = new AgeRatingPipe();
private mangaFormatPipe = new MangaFormatPipe(this.translocoService);
private personRolePipe = new PersonRolePipe();
constructor(private httpClient: HttpClient) { }
@ -74,6 +95,28 @@ export class MetadataService {
return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
}
getGenreWithCounts(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowseGenre[]>>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseGenre[]>;
})
);
}
getTagWithCounts(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowseTag[]>>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseTag[]>;
})
);
}
getAllLanguages(libraries?: Array<number>) {
let method = 'metadata/languages'
if (libraries != undefined && libraries.length > 0) {
@ -110,19 +153,28 @@ export class MetadataService {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'metadata/people-by-role?role=' + role);
}
createDefaultFilterDto(): SeriesFilterV2 {
createDefaultFilterDto<TFilter extends number, TSort extends number>(entityType: ValidFilterEntity): FilterV2<TFilter, TSort> {
return {
statements: [] as FilterStatement[],
statements: [] as FilterStatement<TFilter>[],
combination: FilterCombination.And,
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort
}
};
}
createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') {
createDefaultFilterStatement(entityType: ValidFilterEntity) {
switch (entityType) {
case 'series':
return this.createFilterStatement(FilterField.SeriesName);
case 'person':
return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`);
}
}
createFilterStatement<T extends number = number>(field: T, comparison = FilterComparison.Equal, value = '') {
return {
comparison: comparison,
field: field,
@ -130,7 +182,7 @@ export class MetadataService {
};
}
updateFilter(arr: Array<FilterStatement>, index: number, filterStmt: FilterStatement) {
updateFilter(arr: Array<FilterStatement<number>>, index: number, filterStmt: FilterStatement<number>) {
arr[index].comparison = filterStmt.comparison;
arr[index].field = filterStmt.field;
arr[index].value = filterStmt.value ? filterStmt.value + '' : '';
@ -140,8 +192,6 @@ export class MetadataService {
switch (role) {
case PersonRole.Other:
break;
case PersonRole.Artist:
break;
case PersonRole.CoverArtist:
entity.coverArtists = persons;
break;
@ -183,4 +233,85 @@ export class MetadataService {
break;
}
}
/**
* Used to get the underlying Options (for Metadata Filter Dropdowns)
* @param filterField
* @param entityType
*/
getOptionsForFilterField<T extends number>(filterField: T, entityType: ValidFilterEntity) {
switch (entityType) {
case 'series':
return this.getSeriesOptionsForFilterField(filterField as FilterField);
case 'person':
return this.getPersonOptionsForFilterField(filterField as PersonFilterField);
}
}
private getPersonOptionsForFilterField(field: PersonFilterField) {
switch (field) {
case PersonFilterField.Role:
return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}}));
}
return of([])
}
private getSeriesOptionsForFilterField(field: FilterField) {
switch (field) {
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

@ -6,9 +6,12 @@ import {PaginatedResult} from "../_models/pagination";
import {Series} from "../_models/series";
import {map} from "rxjs/operators";
import {UtilityService} from "../shared/_services/utility.service";
import {BrowsePerson} from "../_models/person/browse-person";
import {BrowsePerson} from "../_models/metadata/browse/browse-person";
import {StandaloneChapter} from "../_models/standalone-chapter";
import {TextResonse} from "../_types/text-response";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
@Injectable({
providedIn: 'root'
@ -43,17 +46,28 @@ export class PersonService {
return this.httpClient.get<Array<StandaloneChapter>>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`);
}
getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) {
getAuthorsToBrowse(filter: FilterV2<PersonFilterField, PersonSortField>, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe(
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
})
);
}
// getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) {
// let params = new HttpParams();
// params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
//
// return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe(
// map((response: any) => {
// return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
// })
// );
// }
downloadCover(personId: number) {
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse);
}

View file

@ -16,13 +16,14 @@ import {TextResonse} from '../_types/text-response';
import {AccountService} from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {PersonalToC} from "../_models/readers/personal-toc";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import NoSleep from 'nosleep.js';
import {FullProgress} from "../_models/readers/full-progress";
import {Volume} from "../_models/volume";
import {UtilityService} from "../shared/_services/utility.service";
import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
import {FilterField} from "../_models/metadata/v2/filter-field";
export const CHAPTER_ID_DOESNT_EXIST = -1;
@ -107,7 +108,7 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
}
getAllBookmarks(filter: SeriesFilterV2 | undefined) {
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
}

View file

@ -1,28 +1,26 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter';
import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response';
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review";
import {HttpClient, HttpParams} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {environment} from 'src/environments/environment';
import {UtilityService} from '../shared/_services/utility.service';
import {Chapter} from '../_models/chapter';
import {PaginatedResult} from '../_models/pagination';
import {Series} from '../_models/series';
import {RelatedSeries} from '../_models/series-detail/related-series';
import {SeriesDetail} from '../_models/series-detail/series-detail';
import {SeriesGroup} from '../_models/series-group';
import {SeriesMetadata} from '../_models/metadata/series-metadata';
import {Volume} from '../_models/volume';
import {TextResonse} from '../_types/text-response';
import {FilterV2} from '../_models/metadata/v2/filter-v2';
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
import {QueryContext} from "../_models/metadata/v2/query-context";
import {ExternalSeries} from "../_models/series-detail/external-series";
import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match";
import {FilterField} from "../_models/metadata/v2/filter-field";
@Injectable({
providedIn: 'root'
@ -33,10 +31,9 @@ export class SeriesService {
paginatedResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService,
private utilityService: UtilityService) { }
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) {
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>, context: QueryContext = QueryContext.None) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -48,7 +45,7 @@ export class SeriesService {
);
}
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -100,7 +97,7 @@ export class SeriesService {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId});
}
getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
@ -116,7 +113,7 @@ export class SeriesService {
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
}
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable<PaginatedResult<Series[]>> {
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>): Observable<PaginatedResult<Series[]>> {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -134,7 +131,7 @@ export class SeriesService {
}));
}
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -230,5 +227,4 @@ export class SeriesService {
updateDontMatch(seriesId: number, dontMatch: boolean) {
return this.httpClient.post<string>(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse);
}
}

View file

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { filter, ReplaySubject, take } from 'rxjs';
import {Injectable} from '@angular/core';
import {NavigationStart, Router} from '@angular/router';
import {filter, ReplaySubject, take} from 'rxjs';
@Injectable({
providedIn: 'root'
@ -29,7 +29,7 @@ export class ToggleService {
this.toggleState = !state;
this.toggleStateSource.next(this.toggleState);
});
}
set(state: boolean) {

View file

@ -33,10 +33,10 @@
} @else {
<div class="d-flex pt-3 justify-content-between">
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
@if (item.series.plusMediaFormat === PlusMediaFormat.Comic) {
<span class="me-1">{{t('issue-count', {num: item.series.chapters})}}</span>
} @else {
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
}
} @else {

View file

@ -0,0 +1,9 @@
<ng-container *transloco="let t">
<button class="btn btn-sm btn-icon" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="disabled()">
@if (isAscending()) {
<i class="fa fa-arrow-up" [title]="t('metadata-filter.ascending-alt')"></i>
} @else {
<i class="fa fa-arrow-down" [title]="t('metadata-filter.descending-alt')"></i>
}
</button>
</ng-container>

View file

@ -0,0 +1,21 @@
import {ChangeDetectionStrategy, Component, input, model} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
@Component({
selector: 'app-sort-button',
imports: [
TranslocoDirective
],
templateUrl: './sort-button.component.html',
styleUrl: './sort-button.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SortButtonComponent {
disabled = input<boolean>(false);
isAscending = model<boolean>(true);
updateSortOrder() {
this.isAscending.set(!this.isAscending());
}
}

View file

@ -3,8 +3,17 @@
<form [formGroup]="filterGroup">
<div class="row g-0">
<div class="col-auto ms-auto">
<label for="match-filter">Match State</label>
<div class="col-auto ms-auto me-3">
<label for="libtype-filter">{{t('library-type')}}</label>
<select class="form-select" formControlName="libraryType" id="libtype-filter">
<option [value]="-1">{{t('all-status-label')}}</option>
@for(libType of allLibraryTypes; track libType) {
<option [value]="libType">{{libType | libraryType}}</option>
}
</select>
</div>
<div class="col-auto">
<label for="match-filter">{{t('matched-state-label')}}</label>
<select class="form-select" formControlName="matchState" id="match-filter">
@for(state of allMatchStates; track state) {
<option [value]="state">{{state | matchStateOption}}</option>

View file

@ -21,20 +21,23 @@ import {LibraryNamePipe} from "../../_pipes/library-name.pipe";
import {AsyncPipe} from "@angular/common";
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {ScanSeriesEvent} from "../../_models/events/scan-series-event";
import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library";
@Component({
selector: 'app-manage-matched-metadata',
imports: [
TranslocoDirective,
ImageComponent,
VirtualScrollerModule,
ReactiveFormsModule,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
LibraryNamePipe,
AsyncPipe,
TranslocoDirective,
ImageComponent,
VirtualScrollerModule,
ReactiveFormsModule,
MatchStateOptionPipe,
UtcToLocalTimePipe,
DefaultValuePipe,
NgxDatatableModule,
LibraryNamePipe,
AsyncPipe,
LibraryTypePipe,
],
templateUrl: './manage-matched-metadata.component.html',
styleUrl: './manage-matched-metadata.component.scss',
@ -44,6 +47,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
protected readonly ColumnMode = ColumnMode;
protected readonly MatchStateOption = MatchStateOption;
protected readonly allMatchStates = allMatchStates.filter(m => m !== MatchStateOption.Matched); // Matched will have too many
protected readonly allLibraryTypes = allKavitaPlusMetadataApplicableTypes;
private readonly licenseService = inject(LicenseService);
private readonly actionService = inject(ActionService);
@ -58,6 +62,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
data: Array<ManageMatchSeries> = [];
filterGroup = new FormGroup({
'matchState': new FormControl(MatchStateOption.Error, []),
'libraryType': new FormControl(-1, []), // Denotes all
});
ngOnInit() {
@ -99,6 +104,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
loadData() {
const filter: ManageMatchFilter = {
matchStateOption: parseInt(this.filterGroup.get('matchState')!.value + '', 10),
libraryType: parseInt(this.filterGroup.get('libraryType')!.value + '', 10),
searchTerm: ''
};

View file

@ -13,10 +13,10 @@
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[trackByIdentity]="trackByIdentity"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpbarKeys"
[trackByIdentity]="trackByIdentity"
[filterSettings]="filterSettings"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">

View file

@ -11,13 +11,12 @@ import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
import {Action, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service';
@ -32,7 +31,15 @@ import {
SideNavCompanionBarComponent
} 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 {FilterV2} from "../../../_models/metadata/v2/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";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {Select2Option} from "ng-select2-component";
@Component({
@ -57,18 +64,19 @@ 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[] = [];
loadingSeries = false;
pagination: Pagination = new Pagination();
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filter: FilterV2<FilterField, SortField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActiveCheck!: SeriesFilterV2;
filterActiveCheck!: FilterV2<FilterField>;
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
browseTitlePipe = new BrowseTitlePipe();
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -124,13 +132,42 @@ export class AllSeriesComponent implements OnInit {
constructor() {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
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.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
}
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter!.name || this.title;
this.titleService.setTitle('Kavita - ' + this.title);
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.presetsV2 = this.filter;
// 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<FilterField>(field, 'series') as Observable<Select2Option[]>).subscribe((opts: Select2Option[]) => {
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.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck();
});
@ -143,7 +180,11 @@ export class AllSeriesComponent implements OnInit {
});
}
updateFilter(data: FilterEvent) {
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<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;

View file

@ -1,8 +1,7 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
import { AdminGuard } from './_guards/admin.guard';
import {NgModule} from '@angular/core';
import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
import {AuthGuard} from './_guards/auth.guard';
import {LibraryAccessGuard} from './_guards/library-access.guard';
const routes: Routes = [
{
@ -51,8 +50,8 @@ const routes: Routes = [
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: 'browse',
loadChildren: () => import('./_routes/browse-routing.module').then(m => m.routes)
},
{
path: 'library',

View file

@ -107,6 +107,7 @@ export class AppComponent implements OnInit {
const vh = window.innerHeight * 0.01;
this.document.documentElement.style.setProperty('--vh', `${vh}px`);
this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint());
this.utilityService.updateUserBreakpoint();
}
ngOnInit(): void {

View file

@ -1,9 +1,16 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit
} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
@ -11,7 +18,7 @@ import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {PageBookmark} from 'src/app/_models/readers/page-bookmark';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {ImageService} from 'src/app/_services/image.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service';
@ -24,9 +31,14 @@ import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../../../_models/metadata/v2/filter-v2";
import {Title} from "@angular/platform-browser";
import {WikiLink} from "../../../_models/wiki";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {MetadataService} from "../../../_services/metadata.service";
@Component({
selector: 'app-bookmarks',
@ -51,6 +63,8 @@ export class BookmarksComponent implements OnInit {
private readonly titleService = inject(Title);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly imageService = inject(ImageService);
public readonly metadataService = inject(MetadataService);
public readonly destroyRef = inject(DestroyRef);
protected readonly WikiLink = WikiLink;
@ -63,27 +77,34 @@ export class BookmarksComponent implements OnInit {
jumpbarKeys: Array<JumpKey> = [];
pagination: Pagination = new Pagination();
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filter: FilterV2<FilterField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActive: boolean = false;
filterActiveCheck!: SeriesFilterV2;
filterActiveCheck!: FilterV2<FilterField>;
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
refresh: EventEmitter<void> = new EventEmitter();
constructor() {
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
}
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
this.filterSettings.presetsV2 = this.filter;
this.filterSettings.statementLimit = 1;
this.cdRef.markForCheck();
});
this.titleService.setTitle('Kavita - ' + translate('bookmarks.title'));
}
@ -190,7 +211,7 @@ export class BookmarksComponent implements OnInit {
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id));
}
updateFilter(data: FilterEvent) {
updateFilter(data: FilterEvent<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;

View file

@ -1,87 +0,0 @@
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 {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',
imports: [
SideNavCompanionBarComponent,
TranslocoDirective,
CardDetailLayoutComponent,
DecimalPipe,
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

@ -0,0 +1,34 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-genres'" >
<app-side-nav-companion-bar [hasFilter]="false">
<h2 title>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="genres"
[pagination]="pagination"
[trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[refresh]="refresh"
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Genres, item.id)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
<span>{{t('issue-count', {num: item.chapterCount | compactNumber})}}</span>
</div>
</div>
</ng-template>
</app-card-detail-layout>
</ng-container>
</div>

View file

@ -0,0 +1 @@
@use '../../../tag-card-common';

View file

@ -0,0 +1,68 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {JumpbarService} from "../../_services/jumpbar.service";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key";
import {MetadataService} from "../../_services/metadata.service";
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {Title} from "@angular/platform-browser";
@Component({
selector: 'app-browse-genres',
imports: [
CardDetailLayoutComponent,
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
],
templateUrl: './browse-genres.component.html',
styleUrl: './browse-genres.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrowseGenresComponent implements OnInit {
protected readonly FilterField = FilterField;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly metadataService = inject(MetadataService);
private readonly jumpbarService = inject(JumpbarService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly titleService = inject(Title);
isLoading = false;
genres: Array<BrowseGenre> = [];
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.titleService.setTitle('Kavita - ' + translate('browse-genres.title'));
this.metadataService.getGenreWithCounts(undefined, undefined).subscribe(d => {
this.genres = d.result;
this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.genres, (d: BrowseGenre) => d.title);
this.isLoading = false;
this.cdRef.markForCheck();
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
}

View file

@ -1,6 +1,6 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-authors'" >
<app-side-nav-companion-bar [hasFilter]="false">
<ng-container *transloco="let t; read:'browse-people'" >
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title>
<span>{{t('title')}}</span>
</h2>
@ -16,13 +16,16 @@
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[refresh]="refresh"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
(applyFilter)="updateFilter($event)"
>
<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 class="tag-meta">
<div style="font-size: 12px">{{t('series-count', {num: item.seriesCount | compactNumber})}}</div>
<div style="font-size: 12px">{{t('issue-count', {num: item.chapterCount | compactNumber})}}</div>
</div>
</ng-template>
</app-person-card>

View file

@ -1,3 +1,5 @@
@use '../../../tag-card-common';
.main-container {
margin-top: 10px;
padding: 0 0 0 10px;

View file

@ -0,0 +1,128 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject} 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 {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key";
import {ActivatedRoute, Router} from "@angular/router";
import {PersonService} from "../../_services/person.service";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {JumpbarService} from "../../_services/jumpbar.service";
import {PersonCardComponent} from "../../cards/person-card/person-card.component";
import {ImageService} from "../../_services/image.service";
import {TranslocoDirective} from "@jsverse/transloco";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {ReactiveFormsModule} from "@angular/forms";
import {PersonSortField} from "../../_models/metadata/v2/person-sort-field";
import {PersonFilterField} from "../../_models/metadata/v2/person-filter-field";
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
import {FilterV2} from "../../_models/metadata/v2/filter-v2";
import {PersonFilterSettings} from "../../metadata-filter/filter-settings";
import {FilterEvent} from "../../_models/metadata/series-filter";
import {PersonRole} from "../../_models/metadata/person";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {MetadataService} from "../../_services/metadata.service";
import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
@Component({
selector: 'app-browse-people',
imports: [
SideNavCompanionBarComponent,
TranslocoDirective,
CardDetailLayoutComponent,
DecimalPipe,
PersonCardComponent,
CompactNumberPipe,
ReactiveFormsModule,
],
templateUrl: './browse-people.component.html',
styleUrl: './browse-people.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BrowsePeopleComponent {
protected readonly PersonSortField = PersonSortField;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly personService = inject(PersonService);
private readonly jumpbarService = inject(JumpbarService);
private readonly route = inject(ActivatedRoute);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly imageService = inject(ImageService);
protected readonly metadataService = inject(MetadataService);
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}`;
filterSettings: PersonFilterSettings = new PersonFilterSettings();
filterActive: boolean = false;
filterOpen: EventEmitter<boolean> = new EventEmitter();
filter: FilterV2<PersonFilterField, PersonSortField> | undefined = undefined;
filterActiveCheck!: FilterV2<PersonFilterField>;
constructor() {
this.isLoading = true;
this.cdRef.markForCheck();
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<PersonFilterField, PersonSortField>;
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('person');
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement<PersonFilterField>);
}
this.filterActiveCheck = this.filterUtilityService.createPersonV2Filter();
this.filterActiveCheck!.statements.push({value: `${PersonRole.Writer},${PersonRole.CoverArtist}`, field: PersonFilterField.Role, comparison: FilterComparison.Contains});
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck();
this.loadData();
});
}
loadData() {
if (!this.filter) {
this.filter = this.metadataService.createDefaultFilterDto('person');
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement<PersonFilterField>);
this.cdRef.markForCheck();
}
this.personService.getAuthorsToBrowse(this.filter!).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]);
}
updateFilter(data: FilterEvent<PersonFilterField, PersonSortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;
if (data.isFirst) {
this.loadData();
return;
}
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => {
this.loadData();
});
}
}

View file

@ -0,0 +1,34 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-tags'" >
<app-side-nav-companion-bar [hasFilter]="false">
<h2 title>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
</app-side-nav-companion-bar>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="tags"
[pagination]="pagination"
[trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[refresh]="refresh"
>
<ng-template #cardItem let-item let-position="idx">
<div class="tag-card" (click)="openFilter(FilterField.Tags, item.id)">
<div class="tag-name">{{ item.title }}</div>
<div class="tag-meta">
<span>{{t('series-count', {num: item.seriesCount | compactNumber})}}</span>
<span>{{t('issue-count', {num: item.chapterCount | compactNumber})}}</span>
</div>
</div>
</ng-template>
</app-card-detail-layout>
</ng-container>
</div>

View file

@ -0,0 +1 @@
@use '../../../tag-card-common';

View file

@ -0,0 +1,67 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {
SideNavCompanionBarComponent
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {MetadataService} from "../../_services/metadata.service";
import {JumpbarService} from "../../_services/jumpbar.service";
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
import {BrowseGenre} from "../../_models/metadata/browse/browse-genre";
import {Pagination} from "../../_models/pagination";
import {JumpKey} from "../../_models/jumpbar/jump-key";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import {BrowseTag} from "../../_models/metadata/browse/browse-tag";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {Title} from "@angular/platform-browser";
@Component({
selector: 'app-browse-tags',
imports: [
CardDetailLayoutComponent,
DecimalPipe,
SideNavCompanionBarComponent,
TranslocoDirective,
CompactNumberPipe
],
templateUrl: './browse-tags.component.html',
styleUrl: './browse-tags.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrowseTagsComponent implements OnInit {
protected readonly FilterField = FilterField;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly metadataService = inject(MetadataService);
private readonly jumpbarService = inject(JumpbarService);
protected readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly titleService = inject(Title);
isLoading = false;
tags: Array<BrowseTag> = [];
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.titleService.setTitle('Kavita - ' + translate('browse-tags.title'));
this.metadataService.getTagWithCounts(undefined, undefined).subscribe(d => {
this.tags = d.result;
this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.tags, (d: BrowseGenre) => d.title);
this.isLoading = false;
this.cdRef.markForCheck();
});
}
openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
}

View file

@ -1,18 +1,18 @@
<ng-container *transloco="let t; read: 'card-detail-layout'">
<app-loading [loading]="isLoading"></app-loading>
@if (header.length > 0) {
@if (header().length > 0) {
<div class="row mt-2 g-0 pb-2">
<div class="col me-auto">
<h4>
@if (actions.length > 0) {
@if (actions().length > 0) {
<span>
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions()" [labelBy]="header()"></app-card-actionables>&nbsp;
</span>
}
<span>
{{header}}&nbsp;
{{header()}}&nbsp;
@if (pagination) {
<span class="badge bg-primary rounded-pill"
[attr.aria-label]="t('total-items', {count: pagination.totalItems})">{{pagination.totalItems}}</span>
@ -24,7 +24,10 @@
</div>
}
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
@if (filterSettings) {
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
}
<div class="viewport-container ms-1" [ngClass]="{'empty': items.length === 0 && !isLoading}">
<div class="content-container">
<div class="card-container">
@ -34,13 +37,14 @@
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="bufferAmount" [parentScroll]="parentScroll">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2"
(click)="tryToSaveJumpKey()"
*ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i"
id="jumpbar-index--{{i}}"
[attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
</div>
@for (item of scroll.viewPortItems; track trackByIdentity(i, item); let i = $index) {
<div class="card col-auto mt-2 mb-2"
(click)="tryToSaveJumpKey()"
id="jumpbar-index--{{i}}"
[attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
</div>
}
</div>
</virtual-scroller>
</div>
@ -54,9 +58,11 @@
<ng-template #cardTemplate>
<virtual-scroller #scroll [items]="items" [bufferAmount]="bufferAmount">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" (click)="tryToSaveJumpKey()" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</virtual-scroller>

View file

@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ContentChild,
DestroyRef,
ElementRef,
@ -10,20 +11,22 @@ import {
HostListener,
inject,
Inject,
input,
Input,
OnChanges,
OnInit,
Output,
signal,
Signal,
SimpleChange,
SimpleChanges,
TemplateRef,
TrackByFunction,
ViewChild
ViewChild,
WritableSignal
} from '@angular/core';
import {NavigationStart, Router} from '@angular/router';
import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Library} from 'src/app/_models/library/library';
@ -35,43 +38,59 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
import {filter, map} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {tap} from "rxjs";
import {FilterV2} from "../../_models/metadata/v2/filter-v2";
import {FilterSettingsBase, ValidFilterEntity} from "../../metadata-filter/filter-settings";
const ANIMATION_TIME_MS = 0;
/**
* Provides a virtualized card layout, jump bar, and metadata filter bar.
*
* How to use:
* - For filtering:
* - pass a filterSettings which will bootstrap the filtering bar
* - pass a jumpbar method binding to calc the count for the entity (not implemented yet)
* - For card layout
* - Pass an identity function for trackby
* - Pass a pagination object for the total count
* - Pass the items
* -
*/
@Component({
selector: 'app-card-detail-layout',
imports: [LoadingComponent, VirtualScrollerModule, CardActionablesComponent, MetadataFilterComponent,
TranslocoDirective, NgTemplateOutlet, NgClass, NgForOf],
templateUrl: './card-detail-layout.component.html',
styleUrls: ['./card-detail-layout.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class CardDetailLayoutComponent implements OnInit, OnChanges {
export class CardDetailLayoutComponent<TFilter extends number, TSort extends number> implements OnInit, OnChanges {
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly jumpbarService = inject(JumpbarService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint;
@Input() header: string = '';
header: Signal<string> = input('');
@Input() isLoading: boolean = false;
@Input() items: any[] = [];
@Input() pagination!: Pagination;
@Input() items: any[] = [];
/**
* Parent scroll for virtualize pagination
*/
@Input() parentScroll!: Element | Window;
// Filter Code
// We need to pass filterOpen from the grandfather to the metadata filter due to the filter button being in a separate component
@Input() filterOpen!: EventEmitter<boolean>;
/**
* Should filtering be shown on the page
@ -80,15 +99,20 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
/**
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = [];
actions: Signal<ActionItem<any>[]> = input([]);
/**
* A trackBy to help with rendering. This is required as without it there are issues when scrolling
*/
@Input({required: true}) trackByIdentity!: TrackByFunction<any>;
@Input() filterSettings!: FilterSettings;
@Input() filterSettings: FilterSettingsBase | undefined = undefined;
entityType = input<ValidFilterEntity | 'other'>();
@Input() refresh!: EventEmitter<void>;
/**
* Will force the jumpbar to be disabled - in cases where you're not using a traditional filter config
*/
customSort = input(false);
@Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
@ -101,13 +125,21 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
libraries: Array<FilterItem<Library>> = [];
updateApplied: number = 0;
bufferAmount: number = 1;
filterSignal: WritableSignal<FilterV2<number, number> | undefined> = signal(undefined);
hasCustomSort = computed(() => {
if (this.customSort()) return true;
if (this.filteringDisabled) return false;
const filter = this.filterSignal();
return filter?.sortOptions?.sortField != SortField.SortName || !filter?.sortOptions.isAscending;
});
constructor(@Inject(DOCUMENT) private document: Document) {}
@ -122,16 +154,12 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
ngOnInit(): void {
if (this.trackByIdentity === undefined) {
this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
}
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
this.cdRef.markForCheck();
this.trackByIdentity = (_: number, item: any) => `${this.header()}_${this.updateApplied}_${item?.id}`;
}
if (this.pagination === undefined) {
this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1};
const items = this.items;
this.pagination = {currentPage: 1, itemsPerPage: items.length, totalItems: items.length, totalPages: 1};
this.cdRef.markForCheck();
}
@ -170,24 +198,16 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
}
}
hasCustomSort() {
if (this.filteringDisabled) return false;
const hasCustomSort = this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending;
//const hasNonDefaultSortField = this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName;
return hasCustomSort;
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, undefined);
}
}
applyMetadataFilter(event: FilterEvent) {
this.applyFilter.emit(event);
applyMetadataFilter(event: FilterEvent<number, number>) {
this.applyFilter.emit(event as FilterEvent<TFilter, TSort>);
this.updateApplied++;
this.filter = event.filterV2;
this.filterSignal.set(event.filterV2);
this.cdRef.markForCheck();
}
@ -208,4 +228,6 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
tryToSaveJumpKey() {
this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex);
}
protected readonly Breakpoint = Breakpoint;
}

View file

@ -46,7 +46,7 @@ import {NextExpectedChapter} from "../../_models/series-detail/next-expected-cha
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
import {BrowsePerson} from "../../_models/person/browse-person";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson;

View file

@ -19,7 +19,7 @@ import {ScrollService} from "../../_services/scroll.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {NgTemplateOutlet} from "@angular/common";
import {BrowsePerson} from "../../_models/person/browse-person";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
import {Person} from "../../_models/metadata/person";
import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component";

View file

@ -19,7 +19,6 @@ import {ToastrService} from 'ngx-toastr';
import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {UserCollection} from 'src/app/_models/collection-tag';
@ -27,7 +26,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added-
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service';
import {CollectionTagService} from 'src/app/_services/collection-tag.service';
@ -49,8 +48,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../../../_models/metadata/v2/filter-v2";
import {AccountService} from "../../../_services/account.service";
import {User} from "../../../_models/user";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
@ -62,6 +60,10 @@ import {
import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
import {MetadataService} from "../../../_services/metadata.service";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
@Component({
selector: 'app-collection-detail',
@ -95,6 +97,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
protected readonly utilityService = inject(UtilityService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService);
private readonly metadataService = inject(MetadataService);
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly Breakpoint = Breakpoint;
@ -109,13 +112,13 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
series: Array<Series> = [];
pagination: Pagination = new Pagination();
collectionTagActions: ActionItem<UserCollection>[] = [];
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filter: FilterV2<FilterField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
summary: string = '';
user!: User;
actionInProgress: boolean = false;
filterActiveCheck!: SeriesFilterV2;
filterActiveCheck!: FilterV2<FilterField>;
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
@ -188,18 +191,26 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
}
const tagId = parseInt(routeId, 10);
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
if (this.filter.statements.filter(stmt => stmt.field === FilterField.CollectionTags).length === 0) {
this.filter!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal});
const defaultStmt = {field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal};
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(defaultStmt);
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal});
if (this.filter.statements.filter((stmt: FilterStatement<FilterField>) => stmt.field === FilterField.CollectionTags).length === 0) {
this.filter!.statements.push(defaultStmt);
}
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck!.statements.push(defaultStmt);
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck();
this.updateTag(tagId);
this.cdRef.markForCheck();
});
}
@ -271,7 +282,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
});
}
updateFilter(data: FilterEvent) {
updateFilter(data: FilterEvent<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;

View file

@ -219,7 +219,7 @@ export class DashboardComponent implements OnInit {
const params: any = {};
params['page'] = 1;
params['title'] = translate('dashboard.recently-updated-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.LastChapterAdded;
filter.sortOptions.isAscending = false;
@ -230,7 +230,7 @@ export class DashboardComponent implements OnInit {
params['page'] = 1;
params['title'] = translate('dashboard.on-deck-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.NotEqual, value: '100'});
if (filter.sortOptions) {
@ -242,7 +242,7 @@ export class DashboardComponent implements OnInit {
const params: any = {};
params['page'] = 1;
params['title'] = translate('dashboard.recently-added-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.Created;
filter.sortOptions.isAscending = false;
@ -252,7 +252,7 @@ export class DashboardComponent implements OnInit {
const params: any = {};
params['page'] = 1;
params['title'] = translate('dashboard.more-in-genre-title', {genre: this.genre?.title});
const filter = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
}

View file

@ -17,7 +17,7 @@ import {SeriesAddedEvent} from '../_models/events/series-added-event';
import {Library} from '../_models/library/library';
import {Pagination} from '../_models/pagination';
import {Series} from '../_models/series';
import {FilterEvent} from '../_models/metadata/series-filter';
import {FilterEvent, SortField} from '../_models/metadata/series-filter';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
import {ActionService} from '../_services/action.service';
import {LibraryService} from '../_services/library.service';
@ -25,7 +25,6 @@ import {EVENTS, MessageHubService} from '../_services/message-hub.service';
import {SeriesService} from '../_services/series.service';
import {NavService} from '../_services/nav.service';
import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service';
import {FilterSettings} from '../metadata-filter/filter-settings';
import {JumpKey} from '../_models/jumpbar/jump-key';
import {SeriesRemovedEvent} from '../_models/events/series-removed-event';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -37,12 +36,14 @@ import {
SideNavCompanionBarComponent
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@jsverse/transloco";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {debounceTime, ReplaySubject, tap} from "rxjs";
import {SeriesFilterSettings} from "../metadata-filter/filter-settings";
import {MetadataService} from "../_services/metadata.service";
@Component({
selector: 'app-library-detail',
@ -68,6 +69,7 @@ export class LibraryDetailComponent implements OnInit {
private readonly filterUtilityService = inject(FilterUtilitiesService);
public readonly navService = inject(NavService);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly metadataService = inject(MetadataService);
libraryId!: number;
libraryName = '';
@ -75,11 +77,11 @@ export class LibraryDetailComponent implements OnInit {
loadingSeries = false;
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
actions: ActionItem<Library>[] = [];
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filter: FilterV2<FilterField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActive: boolean = false;
filterActiveCheck!: SeriesFilterV2;
filterActiveCheck!: FilterV2<FilterField>;
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
bulkLoader: boolean = false;
@ -184,16 +186,19 @@ export class LibraryDetailComponent implements OnInit {
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
if (this.filter.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) {
this.filter!.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
const defaultStmt = {field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal};
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(defaultStmt);
}
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal});
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck!.statements.push(defaultStmt);
this.filterSettings.presetsV2 = this.filter;
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
@ -311,7 +316,7 @@ export class LibraryDetailComponent implements OnInit {
}
}
updateFilter(data: FilterEvent) {
updateFilter(data: FilterEvent<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;

View file

@ -33,8 +33,9 @@
<div infinite-scroll [infiniteScrollDistance]="1" [infiniteScrollThrottle]="50">
@for(item of webtoonImages | async; let index = $index; track item.src) {
<img src="{{item.src}}" style="display: block;" [ngStyle]="{'width': widthOverride$ | async}"
<img src="{{item.src}}" style="display: block;"
[style.filter]="(darkness$ | async) ?? '' | safeStyle"
[style.width]="widthOverride()"
class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}"
rel="nofollow"
alt="image"

View file

@ -1,20 +1,20 @@
import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common';
import {AsyncPipe, DOCUMENT} from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Component, computed,
DestroyRef, effect,
ElementRef,
EventEmitter,
inject,
Inject,
Inject, Injector,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
Renderer2,
Renderer2, Signal,
SimpleChanges,
ViewChild
} from '@angular/core';
@ -25,11 +25,13 @@ import {ReaderService} from '../../../_services/reader.service';
import {PAGING_DIRECTION} from '../../_models/reader-enums';
import {WebtoonImage} from '../../_models/webtoon-image';
import {MangaReaderService} from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {TranslocoDirective} from "@jsverse/transloco";
import {InfiniteScrollModule} from "ngx-infinite-scroll";
import {ReaderSetting} from "../../_models/reader-setting";
import {SafeStylePipe} from "../../../_pipes/safe-style.pipe";
import {UtilityService} from "../../../shared/_services/utility.service";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
/**
* How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load
@ -63,7 +65,7 @@ const enum DEBUG_MODES {
templateUrl: './infinite-scroller.component.html',
styleUrls: ['./infinite-scroller.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe, NgStyle]
imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe]
})
export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit {
@ -71,6 +73,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
private readonly readerService = inject(ReaderService);
private readonly renderer = inject(Renderer2);
private readonly scrollService = inject(ScrollService);
private readonly utilityService = inject(UtilityService);
private readonly injector = inject(Injector);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
@ -91,6 +95,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
*/
@Input({required: true}) urlProvider!: (page: number) => string;
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) readingProfile!: ReadingProfile;
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
@Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>();
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
@ -174,13 +179,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
*/
debugLogFilter: Array<string> = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]'];
/**
* Width override for manual width control
* 2 observables needed to avoid flickering, probably due to data races, when changing the width
* this allows to precisely define execution order
*/
widthOverride$ : Observable<string> = new Observable<string>();
widthSliderValue$ : Observable<string> = new Observable<string>();
readerSettings!: Signal<ReaderSetting>;
widthOverride!: Signal<string>;
get minPageLoaded() {
return Math.min(...Object.values(this.imagesLoaded));
@ -240,30 +240,37 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy,
takeUntilDestroyed(this.destroyRef)
);
// We need the injector as toSignal is only allowed in injection context
// https://angular.dev/guide/signals#injection-context
this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
this.widthSliderValue$ = this.readerSettings$.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
// Automatically updates when the breakpoint changes, or when reader settings changes
this.widthOverride = computed(() => {
const breakpoint = this.utilityService.activeUserBreakpoint();
const value = this.readerSettings().widthSlider;
this.widthOverride$ = this.widthSliderValue$;
if (breakpoint <= this.readingProfile.disableWidthOverride) {
return '';
}
return (parseInt(value) <= 0) ? '' : value + '%';
});
//perform jump so the page stays in view
this.widthSliderValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
effect(() => {
const width = this.widthOverride(); // needs to be at the top for effect to work
this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum);
if(!this.currentPageElem)
return;
let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[];
images.forEach((img) => {
this.renderer.setStyle(img, "width", val);
this.renderer.setStyle(img, "width", width);
});
this.widthOverride$ = this.widthSliderValue$;
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
this.currentPageElem.scrollIntoView();
this.cdRef.markForCheck();
});
}, {injector: this.injector});
if (this.goToPage) {
this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => {

View file

@ -93,7 +93,8 @@
[readerSettings$]="readerSettings$"
[bookmark$]="showBookmarkEffect$"
[pageNum$]="pageNum$"
[showClickOverlay$]="showClickOverlay$">
[showClickOverlay$]="showClickOverlay$"
[readingProfile]="readingProfile">
</app-single-renderer>
<app-double-renderer [image$]="currentImage$"
@ -133,7 +134,8 @@
(loadPrevChapter)="loadPrevChapter()"
[bookmarkPage]="showBookmarkEffectEvent"
[fullscreenToggled]="fullscreenEvent"
[readerSettings$]="readerSettings$">
[readerSettings$]="readerSettings$"
[readingProfile]="readingProfile">
</app-infinite-scroller>
</div>
}

View file

@ -1283,8 +1283,19 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
/**
* Calls the correct prev- or nextPage method based on the direction, readingDirection, and readerMode
*
* readingDirection is ignored when readerMode is Webtoon or UpDown
*
* KeyDirection.Right: right or bottom click
* KeyDirection.Left: left or top click
* @param event
* @param direction
*/
handlePageChange(event: any, direction: KeyDirection) {
if (this.readerMode === ReaderMode.Webtoon) {
// Webtoons and UpDown reading mode should not take ReadingDirection into account
if (this.readerMode === ReaderMode.Webtoon || this.readerMode === ReaderMode.UpDown) {
if (direction === KeyDirection.Right) {
this.nextPage(event);
} else {
@ -1292,6 +1303,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
return;
}
if (direction === KeyDirection.Right) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event);
} else if (direction === KeyDirection.Left) {

View file

@ -3,7 +3,7 @@
[style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle">
@if(currentImage) {
<img alt=" "
style="width: {{widthOverride$ | async}}"
[style.width]="widthOverride()"
#image
[src]="currentImage.src"
id="image-1"

View file

@ -2,15 +2,15 @@ import { DOCUMENT, NgIf, AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component, computed, DestroyRef, effect,
EventEmitter,
inject,
Inject,
Inject, Injector,
Input,
OnInit,
Output
Output, signal, Signal, WritableSignal
} from '@angular/core';
import {combineLatest, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs';
import {combineLatest, combineLatestWith, filter, map, Observable, of, shareReplay, switchMap, tap} from 'rxjs';
import { PageSplitOption } from 'src/app/_models/preferences/page-split-option';
import { ReaderMode } from 'src/app/_models/preferences/reader-mode';
import { LayoutMode } from '../../_models/layout-mode';
@ -18,8 +18,10 @@ import { FITTING_OPTION, PAGING_DIRECTION } from '../../_models/reader-enums';
import { ReaderSetting } from '../../_models/reader-setting';
import { ImageRenderer } from '../../_models/renderer';
import { MangaReaderService } from '../../_service/manga-reader.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {takeUntilDestroyed, toObservable, toSignal} from "@angular/core/rxjs-interop";
import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
import {UtilityService} from "../../../shared/_services/utility.service";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
@Component({
selector: 'app-single-renderer',
@ -30,7 +32,11 @@ import { SafeStylePipe } from '../../../_pipes/safe-style.pipe';
})
export class SingleRendererComponent implements OnInit, ImageRenderer {
private readonly utilityService = inject(UtilityService);
private readonly injector = inject(Injector);
@Input({required: true}) readerSettings$!: Observable<ReaderSetting>;
@Input({required: true}) readingProfile!: ReadingProfile;
@Input({required: true}) image$!: Observable<HTMLImageElement | null>;
@Input({required: true}) bookmark$!: Observable<number>;
@Input({required: true}) showClickOverlay$!: Observable<boolean>;
@ -52,16 +58,14 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
pageNum: number = 0;
maxPages: number = 1;
/**
* Width override for maunal width control
*/
widthOverride$ : Observable<string> = new Observable<string>();
readerSettings!: Signal<ReaderSetting>;
widthOverride!: Signal<string>;
get ReaderMode() {return ReaderMode;}
get LayoutMode() {return LayoutMode;}
constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService,
@Inject(DOCUMENT) private document: Document) { }
@Inject(DOCUMENT) private document: Document) {}
ngOnInit(): void {
this.readerModeClass$ = this.readerSettings$.pipe(
@ -71,12 +75,16 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
takeUntilDestroyed(this.destroyRef)
);
//handle manual width
this.widthOverride$ = this.readerSettings$.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true});
this.widthOverride = computed(() => {
const breakpoint = this.utilityService.activeUserBreakpoint();
const value = this.readerSettings().widthSlider;
if (breakpoint <= this.readingProfile.disableWidthOverride) {
return '';
}
return (parseInt(value) <= 0) ? '' : value + '%';
});
this.emulateBookClass$ = this.readerSettings$.pipe(
map(data => data.emulateBook),

View file

@ -24,7 +24,7 @@
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
<div class="row mb-2">
<div class="col-md-10">
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<app-metadata-row-filter [preset]="filterStmt" [entityType]="entityType()" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2">
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)">
@ -59,7 +59,7 @@
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
<div class="row mb-3">
<div class="col-md-12">
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
<app-metadata-row-filter [preset]="filterStmt" [entityType]="entityType()" (filterStatement)="updateFilter(i, $event)">
<div class="col-md-1 ms-2 col-1">
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)">

View file

@ -5,23 +5,24 @@ import {
DestroyRef,
EventEmitter,
inject,
input,
Input,
OnInit,
Output
} from '@angular/core';
import {MetadataService} from 'src/app/_services/metadata.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {SeriesFilterV2} from 'src/app/_models/metadata/v2/series-filter-v2';
import {FilterV2} from 'src/app/_models/metadata/v2/filter-v2';
import {MetadataFilterRowComponent} from "../metadata-filter-row/metadata-filter-row.component";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {FilterCombination} from "../../../_models/metadata/v2/filter-combination";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {allFields} from "../../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {distinctUntilChanged, tap} from "rxjs/operators";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ValidFilterEntity} from "../../filter-settings";
@Component({
selector: 'app-metadata-builder',
@ -36,15 +37,15 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetadataBuilderComponent implements OnInit {
export class MetadataBuilderComponent<TFilter extends number = number, TSort extends number = number> implements OnInit {
@Input({required: true}) filter!: SeriesFilterV2;
@Input({required: true}) filter!: FilterV2<TFilter, TSort>;
/**
* The number of statements that can be. 0 means unlimited. -1 means none.
*/
@Input() statementLimit = 0;
@Input() availableFilterFields = allFields;
@Output() update: EventEmitter<SeriesFilterV2> = new EventEmitter<SeriesFilterV2>();
entityType = input.required<ValidFilterEntity>();
@Output() update: EventEmitter<FilterV2<TFilter, TSort>> = new EventEmitter<FilterV2<TFilter, TSort>>();
@Output() apply: EventEmitter<void> = new EventEmitter<void>();
private readonly cdRef = inject(ChangeDetectorRef);
@ -52,7 +53,6 @@ export class MetadataBuilderComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint;
formGroup: FormGroup = new FormGroup({});
@ -62,7 +62,9 @@ export class MetadataBuilderComponent implements OnInit {
];
ngOnInit() {
this.formGroup.addControl('comparison', new FormControl<FilterCombination>(this.filter?.combination || FilterCombination.Or, []));
this.formGroup.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap(values => {
this.filter.combination = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterCombination;
this.update.emit(this.filter);
@ -70,7 +72,8 @@ export class MetadataBuilderComponent implements OnInit {
}
addFilter() {
this.filter.statements = [this.metadataService.createDefaultFilterStatement(), ...this.filter.statements];
const statement = this.metadataService.createFilterStatement<TFilter>(this.filterUtilityService.getDefaultFilterField(this.entityType()));
this.filter.statements = [statement, ...this.filter.statements];
this.cdRef.markForCheck();
}
@ -79,9 +82,11 @@ export class MetadataBuilderComponent implements OnInit {
this.cdRef.markForCheck();
}
updateFilter(index: number, filterStmt: FilterStatement) {
updateFilter(index: number, filterStmt: FilterStatement<number>) {
this.metadataService.updateFilter(this.filter.statements, index, filterStmt);
this.update.emit(this.filter);
}
protected readonly Breakpoint = Breakpoint;
}

View file

@ -3,8 +3,8 @@
<div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2">
<select class="form-select me-2" formControlName="input">
@for (field of availableFields; track field) {
<option [value]="field">{{field | filterField}}</option>
@for (field of filterFieldOptions(); track field.value) {
<option [value]="field.value">{{field.title}}</option>
}
</select>
</div>
@ -18,7 +18,7 @@
</div>
<div class="col-md-4 col-10 mb-2">
@if (IsEmptySelected) {
@if (isEmptySelected()) {
@if (predicateType$ | async; as predicateType) {
@switch (predicateType) {
@case (PredicateType.Text) {
@ -50,7 +50,7 @@
<select2 [data]="opts"
formControlName="filterValue"
[hideSelectedItems]="true"
[multiple]="MultipleDropdownAllowed"
[multiple]="isMultiSelectDropdownAllowed()"
[infiniteScroll]="true"
[resettable]="true">
</select2>
@ -62,10 +62,11 @@
</div>
<div class="col pt-2 ms-2">
@if (UiLabel !== null) {
<span class="text-muted">{{t(UiLabel.unit)}}</span>
@if (UiLabel.tooltip) {
<i class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(UiLabel.tooltip)"></i>
@let label = uiLabel();
@if (label !== null) {
<span class="text-muted">{{t(label.unit)}}</span>
@if (label.tooltip) {
<i class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(label.tooltip)"></i>
}
}
</div>

View file

@ -2,32 +2,41 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
EventEmitter,
inject,
Injector,
input,
Input,
OnInit,
Output,
Signal,
} 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 {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';
import {allFields, FilterField} from 'src/app/_models/metadata/v2/filter-field';
import {FilterField} from 'src/app/_models/metadata/v2/filter-field';
import {AsyncPipe} from "@angular/common";
import {FilterFieldPipe} from "../../../_pipes/filter-field.pipe";
import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
import {Select2, Select2Option} from "ng-select2-component";
import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {AgeRatingPipe} from "../../../_pipes/age-rating.pipe";
import {ValidFilterEntity} from "../../filter-settings";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
interface FieldConfig {
type: PredicateType;
baseComparisons: FilterComparison[];
defaultValue: any;
allowsDateComparisons?: boolean;
allowsNumberComparisons?: boolean;
excludesMustContains?: boolean;
allowsIsEmpty?: boolean;
}
enum PredicateType {
Text = 1,
@ -54,42 +63,42 @@ const unitLabels: Map<FilterField, FilterRowUi> = new Map([
[FilterField.ReadLast, new FilterRowUi('unit-read-last')],
]);
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
const NumberFields = [
FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress,
FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast
];
const DropdownFields = [
FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
FilterField.Imprint, FilterField.Team, FilterField.Location
];
const BooleanFields = [FilterField.WantToRead];
const DateFields = [FilterField.ReadingDate];
const DropdownFieldsWithoutMustContains = [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
];
const DropdownFieldsThatIncludeNumberComparisons = [
FilterField.AgeRating
];
const NumberFieldsThatIncludeDateComparisons = [
FilterField.ReleaseYear
];
const FieldsThatShouldIncludeIsEmpty = [
FilterField.Summary, FilterField.UserRating, FilterField.Genres,
FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Imprint, FilterField.Team,
FilterField.Location,
];
// const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name];
// const NumberFields = [
// FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress,
// FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast
// ];
// const DropdownFields = [
// FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
// FilterField.Translators, FilterField.Characters, FilterField.Publisher,
// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
// FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
// FilterField.Writers, FilterField.Genres, FilterField.Libraries,
// FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
// FilterField.Imprint, FilterField.Team, FilterField.Location, PersonFilterField.Role
// ];
// const BooleanFields = [FilterField.WantToRead];
// const DateFields = [FilterField.ReadingDate];
//
// const DropdownFieldsWithoutMustContains = [
// FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
// ];
// const DropdownFieldsThatIncludeNumberComparisons = [
// FilterField.AgeRating
// ];
// const NumberFieldsThatIncludeDateComparisons = [
// FilterField.ReleaseYear
// ];
//
// const FieldsThatShouldIncludeIsEmpty = [
// FilterField.Summary, FilterField.UserRating, FilterField.Genres,
// FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear,
// FilterField.Translators, FilterField.Characters, FilterField.Publisher,
// FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
// FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
// FilterField.Writers, FilterField.Imprint, FilterField.Team,
// FilterField.Location,
// ];
const StringComparisons = [
FilterComparison.Equal,
@ -126,7 +135,6 @@ const BooleanComparisons = [
imports: [
ReactiveFormsModule,
AsyncPipe,
FilterFieldPipe,
FilterComparisonPipe,
NgbTooltip,
TranslocoDirective,
@ -135,60 +143,75 @@ const BooleanComparisons = [
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MetadataFilterRowComponent implements OnInit {
protected readonly FilterComparison = FilterComparison;
protected readonly PredicateType = PredicateType;
export class MetadataFilterRowComponent<TFilter extends number = number, TSort extends number = number> implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
private readonly metadataService = inject(MetadataService);
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly translocoService = inject(TranslocoService);
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
private readonly injector = inject(Injector);
@Input() index: number = 0; // This is only for debugging
/**
* Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter
*/
@Input() preset!: FilterStatement;
@Input() availableFields: Array<FilterField> = allFields;
@Output() filterStatement = new EventEmitter<FilterStatement>();
@Input() preset!: FilterStatement<TFilter>;
entityType = input.required<ValidFilterEntity>();
@Output() filterStatement = new EventEmitter<FilterStatement<TFilter>>();
formGroup: FormGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
'filterValue': new FormControl<string | number>('', []),
});
formGroup!: FormGroup;
validComparisons$: BehaviorSubject<FilterComparison[]> = new BehaviorSubject([FilterComparison.Equal] as FilterComparison[]);
predicateType$: BehaviorSubject<PredicateType> = new BehaviorSubject(PredicateType.Text as PredicateType);
dropdownOptions$ = of<Select2Option[]>([]);
loaded: boolean = false;
private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService);
private readonly ageRatingPipe = new AgeRatingPipe();
get IsEmptySelected() {
return parseInt(this.formGroup.get('comparison')?.value + '', 10) !== FilterComparison.IsEmpty;
}
get UiLabel(): FilterRowUi | null {
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
if (!unitLabels.has(field)) return null;
return unitLabels.get(field) as FilterRowUi;
}
private comparisonSignal!: Signal<FilterComparison>;
private inputSignal!: Signal<TFilter>;
get MultipleDropdownAllowed() {
const comp = parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison;
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
}
isEmptySelected: Signal<boolean> = computed(() => false);
uiLabel: Signal<FilterRowUi | null> = computed(() => null);
isMultiSelectDropdownAllowed: Signal<boolean> = computed(() => false);
filterFieldOptions: Signal<{title: string, value: TFilter}[]> = computed(() => []);
ngOnInit() {
this.formGroup.addControl('input', new FormControl<FilterField>(FilterField.SeriesName, []));
this.formGroup = new FormGroup({
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
'filterValue': new FormControl<string | number>('', []),
'input': new FormControl<TFilter>(this.filterUtilitiesService.getDefaultFilterField<TFilter>(this.entityType()), [])
});
this.comparisonSignal = toSignal<FilterComparison>(
this.formGroup.get('comparison')!.valueChanges.pipe(
startWith(this.formGroup.get('comparison')!.value),
map(d => parseInt(d + '', 10) as FilterComparison)
)
, {requireSync: true, injector: this.injector});
this.inputSignal = toSignal<TFilter>(
this.formGroup.get('input')!.valueChanges.pipe(
startWith(this.formGroup.get('input')!.value),
map(d => parseInt(d + '', 10) as TFilter)
)
, {requireSync: true, injector: this.injector});
this.isEmptySelected = computed(() => this.comparisonSignal() !== FilterComparison.IsEmpty);
this.uiLabel = computed(() => {
if (!unitLabels.has(this.inputSignal())) return null;
return unitLabels.get(this.inputSignal()) as FilterRowUi;
});
this.isMultiSelectDropdownAllowed = computed(() => {
return this.comparisonSignal() === FilterComparison.Contains || this.comparisonSignal() === FilterComparison.NotContains || this.comparisonSignal() === FilterComparison.MustContains;
});
this.filterFieldOptions = computed(() => {
return this.filterUtilitiesService.getFilterFields(this.entityType());
});
this.formGroup.get('input')?.valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)).subscribe((val: string) => this.handleFieldChange(val));
this.populateFromPreset();
@ -200,14 +223,14 @@ export class MetadataFilterRowComponent implements OnInit {
startWith(this.preset.value),
distinctUntilChanged(),
filter(() => {
const inputVal = parseInt(this.formGroup.get('input')?.value, 10) as FilterField;
return DropdownFields.includes(inputVal);
return this.filterUtilitiesService.getDropdownFields<TFilter>(this.entityType()).includes(this.inputSignal());
}),
switchMap((_) => this.getDropdownObservable()),
takeUntilDestroyed(this.destroyRef)
);
this.formGroup!.valueChanges.pipe(
distinctUntilChanged(),
tap(_ => this.propagateFilterUpdate()),
@ -221,11 +244,13 @@ export class MetadataFilterRowComponent implements OnInit {
propagateFilterUpdate() {
const stmt = {
comparison: parseInt(this.formGroup.get('comparison')?.value, 10) as FilterComparison,
field: parseInt(this.formGroup.get('input')?.value, 10) as FilterField,
field: parseInt(this.formGroup.get('input')?.value, 10) as TFilter,
value: this.formGroup.get('filterValue')?.value!
};
if (typeof stmt.value === 'object' && DateFields.includes(stmt.field)) {
const dateFields = this.filterUtilitiesService.getDateFields(this.entityType());
const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType());
if (typeof stmt.value === 'object' && dateFields.includes(stmt.field)) {
stmt.value = this.dateParser.format(stmt.value);
}
@ -239,7 +264,7 @@ export class MetadataFilterRowComponent implements OnInit {
}
if (stmt.comparison !== FilterComparison.IsEmpty) {
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return;
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !booleanFields.includes(stmt.field))) return;
}
this.filterStatement.emit(stmt);
@ -250,15 +275,20 @@ export class MetadataFilterRowComponent implements OnInit {
this.formGroup.get('comparison')?.patchValue(this.preset.comparison);
this.formGroup.get('input')?.patchValue(this.preset.field);
if (StringFields.includes(this.preset.field)) {
const dropdownFields = this.filterUtilitiesService.getDropdownFields<TFilter>(this.entityType());
const stringFields = this.filterUtilitiesService.getStringFields<TFilter>(this.entityType());
const dateFields = this.filterUtilitiesService.getDateFields(this.entityType());
const booleanFields = this.filterUtilitiesService.getBooleanFields(this.entityType());
if (stringFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (BooleanFields.includes(this.preset.field)) {
} else if (booleanFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(val);
} else if (DateFields.includes(this.preset.field)) {
} else if (dateFields.includes(this.preset.field)) {
this.formGroup.get('filterValue')?.patchValue(this.dateParser.parse(val));
}
else if (DropdownFields.includes(this.preset.field)) {
if (this.MultipleDropdownAllowed || val.includes(',')) {
else if (dropdownFields.includes(this.preset.field)) {
if (this.isMultiSelectDropdownAllowed() || val.includes(',')) {
this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10)));
} else {
if (this.preset.field === FilterField.Languages) {
@ -276,72 +306,28 @@ 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([]);
const filterField = this.inputSignal();
return this.metadataService.getOptionsForFilterField<TFilter>(filterField, this.entityType());
}
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;
const inputVal = parseInt(val, 10) as TFilter;
const stringFields = this.filterUtilitiesService.getStringFields<TFilter>(this.entityType());
const dropdownFields = this.filterUtilitiesService.getDropdownFields<TFilter>(this.entityType());
const numberFields = this.filterUtilitiesService.getNumberFields<TFilter>(this.entityType());
const booleanFields = this.filterUtilitiesService.getBooleanFields<TFilter>(this.entityType());
const dateFields = this.filterUtilitiesService.getDateFields<TFilter>(this.entityType());
const fieldsThatShouldIncludeIsEmpty = this.filterUtilitiesService.getFieldsThatShouldIncludeIsEmpty<TFilter>(this.entityType());
const numberFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getNumberFieldsThatIncludeDateComparisons<TFilter>(this.entityType());
const dropdownFieldsThatIncludeDateComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeDateComparisons<TFilter>(this.entityType());
const dropdownFieldsWithoutMustContains = this.filterUtilitiesService.getDropdownFieldsWithoutMustContains<TFilter>(this.entityType());
const dropdownFieldsThatIncludeNumberComparisons = this.filterUtilitiesService.getDropdownFieldsThatIncludeNumberComparisons<TFilter>(this.entityType());
if (StringFields.includes(inputVal)) {
if (stringFields.includes(inputVal)) {
let comps = [...StringComparisons];
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
comps.push(FilterComparison.IsEmpty);
}
@ -356,13 +342,13 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (NumberFields.includes(inputVal)) {
if (numberFields.includes(inputVal)) {
const comps = [...NumberComparisons];
if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) {
if (numberFieldsThatIncludeDateComparisons.includes(inputVal)) {
comps.push(...DateComparisons);
}
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
comps.push(FilterComparison.IsEmpty);
}
@ -378,9 +364,9 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (DateFields.includes(inputVal)) {
if (dateFields.includes(inputVal)) {
const comps = [...DateComparisons];
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
comps.push(FilterComparison.IsEmpty);
}
@ -395,9 +381,9 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (BooleanFields.includes(inputVal)) {
if (booleanFields.includes(inputVal)) {
let comps = [...DateComparisons];
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
comps.push(FilterComparison.IsEmpty);
}
@ -413,15 +399,15 @@ export class MetadataFilterRowComponent implements OnInit {
return;
}
if (DropdownFields.includes(inputVal)) {
if (dropdownFields.includes(inputVal)) {
let comps = [...DropdownComparisons];
if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
if (dropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) {
comps.push(...NumberComparisons);
}
if (DropdownFieldsWithoutMustContains.includes(inputVal)) {
if (dropdownFieldsWithoutMustContains.includes(inputVal)) {
comps = comps.filter(c => c !== FilterComparison.MustContains);
}
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
if (fieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
comps.push(FilterComparison.IsEmpty);
}
@ -443,4 +429,7 @@ export class MetadataFilterRowComponent implements OnInit {
updateIfDateFilled() {
this.propagateFilterUpdate();
}
protected readonly FilterComparison = FilterComparison;
protected readonly PredicateType = PredicateType;
}

View file

@ -1,11 +1,39 @@
import { SeriesFilterV2 } from "../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {SortField} from "../_models/metadata/series-filter";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {FilterField} from "../_models/metadata/v2/filter-field";
export class FilterSettings {
/**
* The set of entities that are supported for rich filtering. Each entity must have its own distinct SortField and FilterField enums.
*/
export type ValidFilterEntity = 'series' | 'person';
export class FilterSettingsBase<TFilter extends number = number, TSort extends number = number> {
presetsV2: FilterV2<TFilter, TSort> | undefined;
sortDisabled = false;
presetsV2: SeriesFilterV2 | undefined;
/**
* The number of statements that can be on the filter. Set to 1 to disable adding more.
*/
statementLimit: number = 0;
saveDisabled: boolean = false;
}
type: ValidFilterEntity = 'series';
supportsSmartFilter: boolean = false;
}
/**
* Filter Settings for Series entity
*/
export class SeriesFilterSettings extends FilterSettingsBase<FilterField, SortField> {
type: ValidFilterEntity = 'series';
supportsSmartFilter = true;
}
/**
* Filter Settings for People entity
*/
export class PersonFilterSettings extends FilterSettingsBase<PersonFilterField, PersonSortField> {
type: ValidFilterEntity = 'person';
}

View file

@ -26,8 +26,8 @@
<div class="filter-section mx-auto pb-3">
<div class="row justify-content-center g-0">
<app-metadata-builder [filter]="filterV2"
[availableFilterFields]="allFilterFields"
[statementLimit]="filterSettings.statementLimit"
[entityType]="filterSettings().type"
[statementLimit]="filterSettings().statementLimit"
(update)="handleFilters($event)">
</app-metadata-builder>
</div>
@ -41,23 +41,21 @@
</div>
<div class="col-md-3 col-sm-9">
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
<button class="btn btn-sm btn-icon" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled">
@if (isAscendingSort) {
<i class="fa fa-arrow-up" [title]="t('ascending-alt')"></i>
} @else {
<i class="fa fa-arrow-down" [title]="t('descending-alt')"></i>
}
</button>
<app-sort-button [disabled]="filterSettings().sortDisabled" (isAscendingChange)="updateSortOrder($event)" [isAscending]="isAscendingSort" />
<select id="sort-options" class="form-select" formControlName="sortField" style="height: 38px;">
@for(field of allSortFields; track field.value) {
@for(field of sortFieldOptions(); track field.value) {
<option [value]="field.value">{{field.title}}</option>
}
</select>
</div>
<div class="col-md-4 col-sm-12" [ngClass]="{'mt-3': utilityService.getActiveBreakpoint() <= Breakpoint.Mobile}">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
</div>
@if (filterSettings().supportsSmartFilter) {
<div class="col-md-4 col-sm-12" [ngClass]="{'mt-3': utilityService.getActiveBreakpoint() <= Breakpoint.Mobile}">
<label for="filter-name" class="form-label">{{t('filter-name-label')}}</label>
<input id="filter-name" type="text" class="form-control" formControlName="name">
</div>
}
@if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) {
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
@ -82,7 +80,7 @@
<button class="btn btn-primary col-6" (click)="apply()"><i class="fa-solid fa-play me-1" aria-hidden="true"></i>{{t('apply')}}</button>
</div>
<div class="col-md-2 col-sm-6 mt-4 pt-2">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings.saveDisabled || !this.sortGroup.get('name')?.value">
<button class="btn btn-primary col-12" (click)="save()" [disabled]="filterSettings().saveDisabled || !this.sortGroup.get('name')?.value">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>
{{t('save')}}
</button>

View file

@ -2,55 +2,61 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ContentChild,
DestroyRef,
effect,
EventEmitter,
inject,
input,
Input,
OnInit,
Output
Output,
Signal
} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap';
import {Breakpoint, UtilityService} from '../shared/_services/utility.service';
import {Library} from '../_models/library/library';
import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter';
import {FilterEvent, FilterItem} from '../_models/metadata/series-filter';
import {ToggleService} from '../_services/toggle.service';
import {FilterSettings} from './filter-settings';
import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2';
import {FilterV2} from '../_models/metadata/v2/filter-v2';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DrawerComponent} from '../shared/drawer/drawer.component';
import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
import {SortFieldPipe} from "../_pipes/sort-field.pipe";
import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component";
import {allFields} from "../_models/metadata/v2/filter-field";
import {FilterService} from "../_services/filter.service";
import {ToastrService} from "ngx-toastr";
import {animate, style, transition, trigger} from "@angular/animations";
import {SortButtonComponent} from "../_single-module/sort-button/sort-button.component";
import {FilterSettingsBase} from "./filter-settings";
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
@Component({
selector: 'app-metadata-filter',
templateUrl: './metadata-filter.component.html',
styleUrls: ['./metadata-filter.component.scss'],
animations: [
trigger('inOutAnimation', [
transition(':enter', [
style({ height: 0, opacity: 0 }),
animate('.5s ease-out', style({ height: 300, opacity: 1 }))
]),
transition(':leave', [
style({ height: 300, opacity: 1 }),
animate('.5s ease-in', style({ height: 0, opacity: 0 }))
])
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet, DrawerComponent,
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
MetadataBuilderComponent, NgClass]
selector: 'app-metadata-filter',
templateUrl: './metadata-filter.component.html',
styleUrls: ['./metadata-filter.component.scss'],
animations: [
trigger('inOutAnimation', [
transition(':enter', [
style({ height: 0, opacity: 0 }),
animate('.5s ease-out', style({ height: 300, opacity: 1 }))
]),
transition(':leave', [
style({ height: 300, opacity: 1 }),
animate('.5s ease-in', style({ height: 0, opacity: 0 }))
])
]),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet, DrawerComponent,
ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule,
MetadataBuilderComponent, NgClass, SortButtonComponent]
})
export class MetadataFilterComponent implements OnInit {
export class MetadataFilterComponent<TFilter extends number = number, TSort extends number = number> implements OnInit {
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
@ -59,18 +65,16 @@ export class MetadataFilterComponent implements OnInit {
private readonly filterService = inject(FilterService);
protected readonly toggleService = inject(ToggleService);
protected readonly translocoService = inject(TranslocoService);
private readonly sortFieldPipe = new SortFieldPipe(this.translocoService);
protected readonly filterUtilitiesService = inject(FilterUtilitiesService);
/**
* This toggles the opening/collapsing of the metadata filter code
*/
@Input() filterOpen: EventEmitter<boolean> = new EventEmitter();
/**
* Should filtering be shown on the page
*/
@Input() filteringDisabled: boolean = false;
@Input({required: true}) filterSettings!: FilterSettings;
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
filterSettings = input.required<FilterSettingsBase<TFilter, TSort>>();
@Output() applyFilter: EventEmitter<FilterEvent<TFilter, TSort>> = new EventEmitter();
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
@ -86,20 +90,23 @@ export class MetadataFilterComponent implements OnInit {
updateApplied: number = 0;
fullyLoaded: boolean = false;
filterV2: SeriesFilterV2 | undefined;
filterV2: FilterV2<TFilter, TSort> | undefined;
sortFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []);
filterFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []);
constructor() {
effect(() => {
const settings = this.filterSettings();
if (settings?.presetsV2) {
this.filterV2 = this.deepClone(settings.presetsV2);
this.cdRef.markForCheck();
}
})
}
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 {
if (this.filterSettings === undefined) {
this.filterSettings = new FilterSettings();
this.cdRef.markForCheck();
}
if (this.filterOpen) {
this.filterOpen.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(openState => {
this.filteringCollapsed = !openState;
@ -109,42 +116,19 @@ export class MetadataFilterComponent implements OnInit {
}
this.filterFieldOptions = computed(() => {
return this.filterUtilitiesService.getFilterFields(this.filterSettings().type);
});
this.sortFieldOptions = computed(() => {
return this.filterUtilitiesService.getSortFields(this.filterSettings().type);
});
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);
@ -177,7 +161,7 @@ export class MetadataFilterComponent implements OnInit {
return clonedObj;
}
handleFilters(filter: SeriesFilterV2) {
handleFilters(filter: FilterV2<TFilter, TSort>) {
this.filterV2 = filter;
}
@ -185,29 +169,34 @@ export class MetadataFilterComponent implements OnInit {
loadFromPresetsAndSetup() {
this.fullyLoaded = false;
this.filterV2 = this.deepClone(this.filterSettings.presetsV2);
const currentFilterSettings = this.filterSettings();
this.filterV2 = this.deepClone(currentFilterSettings.presetsV2);
const defaultSortField = this.sortFieldOptions()[0].value;
this.sortGroup = new FormGroup({
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []),
sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || defaultSortField, disabled: this.filterSettings().sortDisabled}, []),
limitTo: new FormControl(this.filterV2?.limitTo || 0, []),
name: new FormControl(this.filterV2?.name || '', [])
});
if (this.filterSettings?.presetsV2?.sortOptions) {
this.isAscendingSort = this.filterSettings?.presetsV2?.sortOptions!.isAscending;
if (this.filterSettings()?.presetsV2?.sortOptions) {
this.isAscendingSort = this.filterSettings()?.presetsV2?.sortOptions!.isAscending || true;
}
this.cdRef.markForCheck();
this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10)
};
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10);
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
this.cdRef.markForCheck();
if (this.filterV2?.sortOptions === null) {
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort
};
}
this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort;
this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0);
this.filterV2!.name = this.sortGroup.get('name')?.value || '';
this.cdRef.markForCheck();
});
this.fullyLoaded = true;
@ -215,13 +204,16 @@ export class MetadataFilterComponent implements OnInit {
}
updateSortOrder() {
if (this.filterSettings.sortDisabled) return;
this.isAscendingSort = !this.isAscendingSort;
updateSortOrder(isAscending: boolean) {
if (this.filterSettings().sortDisabled) return;
this.isAscendingSort = isAscending;
if (this.filterV2?.sortOptions === null) {
const defaultSortField = this.sortFieldOptions()[0].value as TSort;
this.filterV2.sortOptions = {
isAscending: this.isAscendingSort,
sortField: SortField.SortName
sortField: defaultSortField
}
}
@ -235,7 +227,7 @@ export class MetadataFilterComponent implements OnInit {
}
apply() {
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!});
this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!} as FilterEvent<TFilter, TSort>);
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) {
this.toggleSelected();
@ -259,9 +251,6 @@ export class MetadataFilterComponent implements OnInit {
this.cdRef.markForCheck();
}
setToggle(event: any) {
this.toggleService.set(!this.filteringCollapsed);
}
protected readonly Breakpoint = Breakpoint;
}

View file

@ -206,6 +206,8 @@
</button>
<div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a>
<a ngbDropdownItem routerLink="/browse/genres">{{t('browse-genres')}}</a>
<a ngbDropdownItem routerLink="/browse/tags">{{t('browse-tags')}}</a>
<a ngbDropdownItem routerLink="/announcements/">{{t('announcements')}}</a>
<a ngbDropdownItem [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>

View file

@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {WikiLink} from "../../../_models/wiki";
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
import {MetadataService} from "../../../_services/metadata.service";
@Component({
selector: 'app-nav-header',
@ -70,6 +71,8 @@ export class NavHeaderComponent implements OnInit {
protected readonly imageService = inject(ImageService);
protected readonly utilityService = inject(UtilityService);
protected readonly modalService = inject(NgbModal);
protected readonly metadataService = inject(MetadataService);
protected readonly FilterField = FilterField;
protected readonly WikiLink = WikiLink;
@ -159,9 +162,9 @@ export class NavHeaderComponent implements OnInit {
});
}
goTo(statement: FilterStatement) {
goTo(statement: FilterStatement<number>) {
let params: any = {};
const filter = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
filter.statements = [statement];
params['page'] = 1;
this.clearSearch();

View file

@ -23,7 +23,7 @@ import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.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 {FilterV2} from "../_models/metadata/v2/filter-v2";
import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field";
import {Series} from "../_models/series";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -44,6 +44,7 @@ import {SafeUrlPipe} from "../_pipes/safe-url.pipe";
import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component";
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component";
import {MetadataService} from "../_services/metadata.service";
interface PersonMergeEvent {
srcId: number,
@ -87,6 +88,7 @@ export class PersonDetailComponent implements OnInit {
private readonly themeService = inject(ThemeService);
private readonly toastr = inject(ToastrService);
private readonly messageHubService = inject(MessageHubService)
private readonly metadataService = inject(MetadataService)
protected readonly FilterField = FilterField;
@ -98,7 +100,7 @@ export class PersonDetailComponent implements OnInit {
roles$: Observable<PersonRole[]> | null = null;
roles: PersonRole[] | null = null;
works$: Observable<Series[]> | null = null;
filter: SeriesFilterV2 | null = null;
filter: FilterV2<FilterField> | null = null;
personActions: Array<ActionItem<Person>> = this.actionService.getPersonActions(this.handleAction.bind(this));
chaptersByRole: any = {};
anilistUrl: string = '';
@ -181,7 +183,7 @@ export class PersonDetailComponent implements OnInit {
}
createFilter(roles: PersonRole[]) {
const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
const filter = this.metadataService.createDefaultFilterDto('series');
filter.combination = FilterCombination.Or;
filter.limitTo = 20;
@ -217,7 +219,7 @@ export class PersonDetailComponent implements OnInit {
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();
const searchFilter = this.metadataService.createDefaultFilterDto('series');
searchFilter.limitTo = 0;
searchFilter.combination = FilterCombination.Or;

View file

@ -31,7 +31,8 @@ import {User} from "../../../_models/user";
templateUrl: './reading-lists.component.html',
styleUrls: ['./reading-lists.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, CardDetailLayoutComponent, CardItemComponent, DecimalPipe, TranslocoDirective, BulkOperationsComponent]
imports: [SideNavCompanionBarComponent, CardActionablesComponent, CardDetailLayoutComponent, CardItemComponent,
DecimalPipe, TranslocoDirective, BulkOperationsComponent]
})
export class ReadingListsComponent implements OnInit {
protected readonly WikiLink = WikiLink;

View file

@ -895,10 +895,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck();
}
this.isLoading = false;
this.cdRef.markForCheck();
});
@ -1092,19 +1088,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
openVolume(volume: Volume) {
if (this.bulkSelectionService.hasSelections()) return;
if (volume.chapters === undefined || volume.chapters?.length === 0) {
this.toastr.error(this.translocoService.translate('series-detail.no-chapters'));
return;
}
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'volume', volume.id]);
return;
this.readerService.readVolume(this.libraryId, this.seriesId, volume, false);
}
openEditChapter(chapter: Chapter) {
const ref = this.modalService.open(EditChapterModalComponent, DefaultModalOptions);

View file

@ -1,24 +1,16 @@
import { HttpClient } from '@angular/common/http';
import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Inject, Injectable} from '@angular/core';
import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment';
import { ConfirmService } from '../confirm.service';
import { Chapter } from 'src/app/_models/chapter';
import { Volume } from 'src/app/_models/volume';
import {
asyncScheduler,
BehaviorSubject,
Observable,
tap,
finalize,
of,
filter,
} from 'rxjs';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
import {Series} from 'src/app/_models/series';
import {environment} from 'src/environments/environment';
import {ConfirmService} from '../confirm.service';
import {Chapter} from 'src/app/_models/chapter';
import {Volume} from 'src/app/_models/volume';
import {asyncScheduler, BehaviorSubject, filter, finalize, Observable, of, tap,} from 'rxjs';
import {download, Download} from '../_models/download';
import {PageBookmark} from 'src/app/_models/readers/page-bookmark';
import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { BytesPipe } from 'src/app/_pipes/bytes.pipe';
import {AccountService} from 'src/app/_services/account.service';
import {BytesPipe} from 'src/app/_pipes/bytes.pipe';
import {translate} from "@jsverse/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SAVER, Saver} from "../../_providers/saver.provider";
@ -26,7 +18,7 @@ import {UtilityService} from "./utility.service";
import {UserCollection} from "../../_models/collection-tag";
import {RecentlyAddedItem} from "../../_models/recently-added-item";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {BrowsePerson} from "../../_models/person/browse-person";
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
export const DEBOUNCE_TIME = 100;

View file

@ -1,18 +1,27 @@
import {inject, Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, Params, Router} from '@angular/router';
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
import {Params, Router} from '@angular/router';
import {allSeriesSortFields, SortField} from 'src/app/_models/metadata/series-filter';
import {MetadataService} from "../../_services/metadata.service";
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
import {FilterV2} from "../../_models/metadata/v2/filter-v2";
import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {allSeriesFilterFields, FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
import { HttpClient } from "@angular/common/http";
import {HttpClient} from "@angular/common/http";
import {TextResonse} from "../../_types/text-response";
import {environment} from "../../../environments/environment";
import {map, tap} from "rxjs/operators";
import {of, switchMap} from "rxjs";
import {Location} from "@angular/common";
import {switchMap} from "rxjs";
import {allPersonFilterFields, PersonFilterField} from "../../_models/metadata/v2/person-filter-field";
import {allPersonSortFields} from "../../_models/metadata/v2/person-sort-field";
import {
FilterSettingsBase,
PersonFilterSettings,
SeriesFilterSettings,
ValidFilterEntity
} from "../../metadata-filter/filter-settings";
import {SortFieldPipe} from "../../_pipes/sort-field.pipe";
import {GenericFilterFieldPipe} from "../../_pipes/generic-filter-field.pipe";
import {TranslocoService} from "@jsverse/transloco";
@Injectable({
@ -20,59 +29,64 @@ import {Location} from "@angular/common";
})
export class FilterUtilitiesService {
private readonly location = inject(Location);
private readonly router = inject(Router);
private readonly metadataService = inject(MetadataService);
private readonly http = inject(HttpClient);
private readonly translocoService = inject(TranslocoService);
private apiUrl = environment.apiUrl;
private readonly sortFieldPipe = new SortFieldPipe(this.translocoService);
private readonly genericFilterFieldPipe = new GenericFilterFieldPipe();
encodeFilter(filter: SeriesFilterV2 | undefined) {
private readonly apiUrl = environment.apiUrl;
encodeFilter(filter: FilterV2 | undefined) {
return this.http.post<string>(this.apiUrl + 'filter/encode', filter, TextResonse);
}
decodeFilter(encodedFilter: string) {
return this.http.post<SeriesFilterV2>(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => {
return this.http.post<FilterV2>(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => {
if (filter == null) {
filter = this.metadataService.createDefaultFilterDto();
filter.statements.push(this.createSeriesV2DefaultStatement());
filter = this.metadataService.createDefaultFilterDto('series');
filter.statements.push(this.metadataService.createDefaultFilterStatement('series'));
}
return filter;
}))
}
updateUrlFromFilter(filter: SeriesFilterV2 | undefined) {
/**
* Encodes the filter and patches into the url
* @param filter
*/
updateUrlFromFilter(filter: FilterV2 | undefined) {
return this.encodeFilter(filter).pipe(tap(encodedFilter => {
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0]+ '?' + encodedFilter);
}));
}
filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot) {
const filter = this.metadataService.createDefaultFilterDto();
filter.statements.push(this.createSeriesV2DefaultStatement());
if (!window.location.href.includes('?')) return of(filter);
return this.decodeFilter(window.location.href.split('?')[1]);
}
/**
* Applies and redirects to the passed page with the filter encoded
* Applies and redirects to the passed page with the filter encoded (Series only)
* @param page
* @param filter
* @param comparison
* @param value
*/
applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
const dto = this.createSeriesV2Filter();
dto.statements.push(this.metadataService.createDefaultFilterStatement(filter, comparison, value + ''));
const dto = this.metadataService.createDefaultFilterDto('series');
dto.statements.push(this.metadataService.createFilterStatement(filter, comparison, value + ''));
return this.encodeFilter(dto).pipe(switchMap(encodedFilter => {
return this.router.navigateByUrl(page.join('/') + '?' + encodedFilter);
}));
}
applyFilterWithParams(page: Array<any>, filter: SeriesFilterV2, extraParams: Params) {
/**
* (Series only)
* @param page
* @param filter
* @param extraParams
*/
applyFilterWithParams(page: Array<any>, filter: FilterV2<any>, extraParams: Params) {
return this.encodeFilter(filter).pipe(switchMap(encodedFilter => {
let url = page.join('/') + '?' + encodedFilter;
url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join('');
@ -81,23 +95,228 @@ export class FilterUtilitiesService {
}));
}
createSeriesV2Filter(): SeriesFilterV2 {
return {
combination: FilterCombination.And,
statements: [],
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
},
};
createPersonV2Filter(): FilterV2<PersonFilterField> {
return {
combination: FilterCombination.And,
statements: [],
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
},
};
}
createSeriesV2DefaultStatement(): FilterStatement {
return {
comparison: FilterComparison.Equal,
value: '',
field: FilterField.SeriesName
}
/**
* Returns the Sort Fields for the Metadata filter based on the entity.
* @param type
*/
getSortFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return allSeriesSortFields.map(f => {
return {title: this.sortFieldPipe.transform(f, type), value: f};
}).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[];
case 'person':
return allPersonSortFields.map(f => {
return {title: this.sortFieldPipe.transform(f, type), value: f};
}).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[];
default:
return [] as {title: string, value: T}[];
}
}
/**
* Returns the Filter Fields for the Metadata filter based on the entity.
* @param type
*/
getFilterFields<T extends number>(type: ValidFilterEntity): {title: string, value: T}[] {
switch (type) {
case 'series':
return allSeriesFilterFields.map(f => {
return {title: this.genericFilterFieldPipe.transform(f, type), value: f};
}).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[];
case 'person':
return allPersonFilterFields.map(f => {
return {title: this.genericFilterFieldPipe.transform(f, type), value: f};
}).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[];
default:
return [] as {title: string, value: T}[];
}
}
/**
* Returns the default field for the Series or Person entity aka what should be there if there are no statements
* @param type
*/
getDefaultFilterField<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return FilterField.SeriesName as unknown as T;
case 'person':
return PersonFilterField.Role as unknown as T;
}
}
/**
* Returns the appropriate Dropdown Fields based on the entity type
* @param type
*/
getDropdownFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
FilterField.Imprint, FilterField.Team, FilterField.Location
] as unknown as T[];
case 'person':
return [
PersonFilterField.Role
] as unknown as T[];
}
}
/**
* Returns the applicable String fields
* @param type
*/
getStringFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name
] as unknown as T[];
case 'person':
return [
PersonFilterField.Name
] as unknown as T[];
}
}
getNumberFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress,
FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast
] as unknown as T[];
case 'person':
return [
PersonFilterField.ChapterCount, PersonFilterField.SeriesCount
] as unknown as T[];
}
}
getBooleanFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.WantToRead
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getDateFields<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.ReadingDate
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getNumberFieldsThatIncludeDateComparisons<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.ReleaseYear
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getDropdownFieldsThatIncludeDateComparisons<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.AgeRating
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getDropdownFieldsWithoutMustContains<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getDropdownFieldsThatIncludeNumberComparisons<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.AgeRating
] as unknown as T[];
case 'person':
return [
] as unknown as T[];
}
}
getFieldsThatShouldIncludeIsEmpty<T extends number>(type: ValidFilterEntity) {
switch (type) {
case 'series':
return [
FilterField.Summary, FilterField.UserRating, FilterField.Genres,
FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear,
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Imprint, FilterField.Team,
FilterField.Location
] as unknown as T[];
case 'person':
return [] as unknown as T[];
}
}
getDefaultSettings(entityType: ValidFilterEntity | "other" | undefined): FilterSettingsBase<any, any> {
if (entityType === 'other' || entityType === undefined) {
// It doesn't matter, return series type
return new SeriesFilterSettings();
}
if (entityType == 'series') return new SeriesFilterSettings();
if (entityType == 'person') return new PersonFilterSettings();
return new SeriesFilterSettings();
}
}

View file

@ -1,5 +1,5 @@
import {HttpParams} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Inject, Injectable, signal, Signal} from '@angular/core';
import {Chapter} from 'src/app/_models/chapter';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
@ -8,6 +8,8 @@ import {Series} from 'src/app/_models/series';
import {Volume} from 'src/app/_models/volume';
import {translate} from "@jsverse/transloco";
import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
import {DOCUMENT} from "@angular/common";
import getComputedStyle from "@popperjs/core/lib/dom-utils/getComputedStyle";
export enum KEY_CODES {
RIGHT_ARROW = 'ArrowRight',
@ -27,12 +29,37 @@ export enum KEY_CODES {
SHIFT = 'Shift'
}
/**
* Use {@link UserBreakpoint} and {@link UtilityService.activeUserBreakpoint} for breakpoint that should depend on user settings
*/
export enum Breakpoint {
Mobile = 768,
Tablet = 1280,
Desktop = 1440
}
/*
Breakpoints, but they're derived from css vars in the theme
*/
export enum UserBreakpoint {
/**
* This is to be used in the UI/as value to disable the functionality with breakpoint, will not actually be set as a breakpoint
*/
Never = 0,
/**
* --mobile-breakpoint
*/
Mobile = 1,
/**
* --tablet-breakpoint
*/
Tablet = 2,
/**
* --desktop-breakpoint, does not actually matter as everything that's not mobile or tablet will be desktop
*/
Desktop = 3,
}
@Injectable({
providedIn: 'root'
@ -42,11 +69,19 @@ export class UtilityService {
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true}));
/**
* The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded
*/
public readonly activeUserBreakpoint = signal<UserBreakpoint>(UserBreakpoint.Never);
// TODO: I need an isPhone/Tablet so that I can easily trigger different views
mangaFormatKeys: string[] = [];
constructor(@Inject(DOCUMENT) private document: Document) {
}
sortChapters = (a: Chapter, b: Chapter) => {
return a.minNumber - b.minNumber;
@ -132,6 +167,34 @@ export class UtilityService {
return Breakpoint.Desktop;
}
updateUserBreakpoint(): void {
this.activeUserBreakpoint.set(this.getActiveUserBreakpoint());
}
private getActiveUserBreakpoint(): UserBreakpoint {
const style = getComputedStyle(this.document.body)
const mobileBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-mobile-breakpoint'), Breakpoint.Mobile);
const tabletBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-tablet-breakpoint'), Breakpoint.Tablet);
//const desktopBreakPoint = this.parseOrDefault<number>(style.getPropertyValue('--setting-desktop-breakpoint'), Breakpoint.Desktop);
if (window.innerWidth <= mobileBreakPoint) {
return UserBreakpoint.Mobile;
} else if (window.innerWidth <= tabletBreakPoint) {
return UserBreakpoint.Tablet;
}
// Fallback to desktop
return UserBreakpoint.Desktop;
}
private parseOrDefault<T>(s: string, def: T): T {
const ret = parseInt(s, 10);
if (isNaN(ret)) {
return def;
}
return ret as T;
}
isInViewport(element: Element, additionalTopOffset: number = 0) {
const rect = element.getBoundingClientRect();
return (

View file

@ -1,14 +1,13 @@
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output} from '@angular/core';
import {APP_BASE_HREF, NgClass, NgIf} from '@angular/common';
import {APP_BASE_HREF, NgClass} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
import {StreamNamePipe} from "../../../_pipes/stream-name.pipe";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
@Component({
selector: 'app-dashboard-stream-list-item',
imports: [TranslocoDirective, StreamNamePipe, NgClass, NgIf],
imports: [TranslocoDirective, StreamNamePipe, NgClass],
templateUrl: './dashboard-stream-list-item.component.html',
styleUrls: ['./dashboard-stream-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -76,7 +76,7 @@
@case (SideNavStreamType.BrowseAuthors) {
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-users" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-users" [title]="t('browse-people')" link="/browse/authors/"></app-side-nav-item>
}
@case (SideNavStreamType.SmartFilter) {

View file

@ -38,7 +38,7 @@ import {ReadingProfileService} from "../../../_services/reading-profile.service"
export class SideNavComponent implements OnInit {
protected readonly WikiLink = WikiLink;
protected readonly ItemLimit = 10;
protected readonly ItemLimit = 13;
protected readonly SideNavStreamType = SideNavStreamType;
protected readonly SettingsTabId = SettingsTabId;
protected readonly Breakpoint = Breakpoint;

View file

@ -4,7 +4,6 @@ import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {StreamNamePipe} from "../../../_pipes/stream-name.pipe";
import {TranslocoDirective} from "@jsverse/transloco";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {RouterLink} from "@angular/router";
@Component({
selector: 'app-sidenav-stream-list-item',

View file

@ -20,7 +20,13 @@ import {
} from 'src/app/admin/_modals/directory-picker/directory-picker.component';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {allLibraryTypes, Library, LibraryType} from 'src/app/_models/library/library';
import {
allKavitaPlusMetadataApplicableTypes,
allKavitaPlusScrobbleEligibleTypes,
allLibraryTypes,
Library,
LibraryType
} from 'src/app/_models/library/library';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
import {UploadService} from 'src/app/_services/upload.service';
@ -103,8 +109,8 @@ export class LibrarySettingsModalComponent implements OnInit {
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
@ -125,13 +131,12 @@ export class LibrarySettingsModalComponent implements OnInit {
get IsKavitaPlusEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel;
return allKavitaPlusScrobbleEligibleTypes.includes(libType);
}
get IsMetadataDownloadEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel
|| libType === LibraryType.ComicVine || libType === LibraryType.Comic;
return allKavitaPlusMetadataApplicableTypes.includes(libType);
}
ngOnInit(): void {
@ -232,6 +237,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('allowMetadataMatching')?.disable();
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)

View file

@ -9,7 +9,7 @@
@for(section of sections; track section.title + section.children.length; let idx = $index;) {
@if (hasAnyChildren(user, section)) {
<h5 class="side-nav-header mb-2 ms-3" [ngClass]="{'mt-4': idx > 0}">{{t(section.title)}}</h5>
<h5 class="side-nav-header mb-2" [ngClass]="{'mt-4': idx > 0}">{{t(section.title)}}</h5>
@for(item of section.children; track item.fragment) {
@if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) {
<app-side-nav-item [id]="'nav-item-' + item.fragment" [noIcon]="true" link="/settings" [fragment]="item.fragment" [title]="item.fragment | settingFragment" [badgeCount]="item.badgeCount$ | async"></app-side-nav-item>

View file

@ -192,6 +192,7 @@ export class PreferenceNavComponent implements AfterViewInit {
} else {
return this.manageService.getAllKavitaPlusSeries({
matchStateOption: MatchStateOption.Error,
libraryType: -1,
searchTerm: ''
}).pipe(
takeUntilDestroyed(this.destroyRef),
@ -272,13 +273,11 @@ export class PreferenceNavComponent implements AfterViewInit {
hasAnyChildren(user: User, section: PrefSection) {
// Filter out items where the user has a restricted role
const visibleItems = section.children.filter(item =>
item.restrictRoles.length === 0 || !this.accountService.hasAnyRole(user, item.restrictRoles)
(item.restrictRoles.length === 0 || !this.accountService.hasAnyRestrictedRole(user, item.restrictRoles)) &&
(item.roles.length === 0 || this.accountService.hasAnyRole(user, item.roles))
);
// Check if the user has any allowed roles in the remaining items
return visibleItems.some(item =>
this.accountService.hasAnyRole(user, item.roles)
);
return visibleItems.length > 0;
}
collapse() {

View file

@ -10,13 +10,13 @@
</div>
<p class="ps-2">{{t('description')}}</p>
<p class="ps-2 text-muted">{{t('extra-tip')}}</p>
<p class="ps-2 text-muted">{{t('extra-tip')}} <a target="_blank" rel="noopener noreferrer" [href]="WikiLink.ReadingProfiles">{{t('wiki-title')}}</a></p>
<div class="row g-0 ">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
<div class="pe-2">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller mb-2 mb-sm-0">
<div class="pe-sm-2">
@if (readingProfiles.length < virtualScrollerBreakPoint) {
@for (readingProfile of readingProfiles; track readingProfile.id) {
@ -32,7 +32,7 @@
</div>
</div>
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-0 ps-sm-3">
<div class="card p-3">
@if (selectedProfile === null) {
<p class="ps-2">{{t('no-selected')}}</p>
@ -46,7 +46,9 @@
<div class="mb-2 d-flex justify-content-between align-items-center">
<app-setting-item [title]="''" [showEdit]="false" [canEdit]="selectedProfile.kind !== ReadingProfileKind.Default">
<ng-template #view>
{{readingProfileForm.get('name')!.value}}
<span [class.clickable]="selectedProfile.kind !== ReadingProfileKind.Default">
{{readingProfileForm.get('name')!.value}}
</span>
</ng-template>
<ng-template #edit>
<input class="form-control" type="text" formControlName="name" [disabled]="selectedProfile.kind === ReadingProfileKind.Default">
@ -250,6 +252,22 @@
</div>
}
<div class="row g-0 mt-4 mb-4">
<app-setting-item [title]="t('disable-width-override-label')" [subtitle]="t('disable-width-override-tooltip')">
<ng-template #view>
{{readingProfileForm.get('disableWidthOverride')!.value | breakpoint}}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="image-reader-heading"
formControlName="disableWidthOverride">
@for (opt of breakPoints; track opt) {
<option [value]="opt">{{opt | breakpoint}}</option>
}
</select>
</ng-template>
</app-setting-item>
</div>
</div>
}
</ng-template>

View file

@ -1,7 +1,5 @@
@use '../../../series-detail-common';
.group-item {
background-color: transparent;

View file

@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnIni
import {ReadingProfileService} from "../../_services/reading-profile.service";
import {
bookLayoutModes,
bookWritingStyles,
bookWritingStyles, breakPoints,
layoutModes,
pageSplitOptions,
pdfScrollModes,
@ -47,6 +47,8 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ToastrService} from "ngx-toastr";
import {ConfirmService} from "../../shared/confirm.service";
import {WikiLink} from "../../_models/wiki";
import {BreakpointPipe} from "../../_pipes/breakpoint.pipe";
enum TabId {
ImageReader = "image-reader",
@ -85,6 +87,7 @@ enum TabId {
NgbNavOutlet,
LoadingComponent,
NgbTooltip,
BreakpointPipe,
],
templateUrl: './manage-reading-profiles.component.html',
styleUrl: './manage-reading-profiles.component.scss',
@ -193,6 +196,7 @@ export class ManageReadingProfilesComponent implements OnInit {
this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, []));
this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, []));
this.readingProfileForm.addControl('widthOverride', new FormControl(this.selectedProfile.widthOverride, [Validators.min(0), Validators.max(100)]));
this.readingProfileForm.addControl('disableWidthOverride', new FormControl(this.selectedProfile.disableWidthOverride, []))
// Epub reader
this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, []));
@ -237,10 +241,10 @@ export class ManageReadingProfilesComponent implements OnInit {
} else {
const profile = this.packData();
this.readingProfileService.updateProfile(profile).subscribe({
next: _ => {
next: newProfile => {
this.readingProfiles = this.readingProfiles.map(p => {
if (p.id !== profile.id) return p;
return profile;
return newProfile;
});
this.cdRef.markForCheck();
},
@ -260,6 +264,7 @@ export class ManageReadingProfilesComponent implements OnInit {
data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string);
data.readerMode = parseInt(data.readerMode as unknown as string);
data.layoutMode = parseInt(data.layoutMode as unknown as string);
data.disableWidthOverride = parseInt(data.disableWidthOverride as unknown as string);
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string);
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string);
@ -316,4 +321,6 @@ export class ManageReadingProfilesComponent implements OnInit {
protected readonly pdfScrollModes = pdfScrollModes;
protected readonly TabId = TabId;
protected readonly ReadingProfileKind = ReadingProfileKind;
protected readonly WikiLink = WikiLink;
protected readonly breakPoints = breakPoints;
}

View file

@ -170,7 +170,7 @@
[libraryId]="libraryId"
[libraryType]="libraryType"
[actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, volume.chapters.length, $event)"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, volume!.chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"
></app-chapter-card>
}

Some files were not shown because too many files have changed in this diff Show more