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:
parent
00c4712fc3
commit
c52ed1f65d
147 changed files with 6612 additions and 958 deletions
30
UI/Web/src/_tag-card-common.scss
Normal file
30
UI/Web/src/_tag-card-common.scss
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
6
UI/Web/src/app/_models/metadata/browse/browse-genre.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse/browse-genre.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Genre} from "../genre";
|
||||
|
||||
export interface BrowseGenre extends Genre {
|
||||
seriesCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {Person} from "../metadata/person";
|
||||
import {Person} from "../person";
|
||||
|
||||
export interface BrowsePerson extends Person {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Tag} from "../../tag";
|
||||
|
||||
export interface BrowseTag extends Tag {
|
||||
seriesCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import {PersonRole} from "../person";
|
||||
import {PersonSortOptions} from "./sort-options";
|
||||
|
||||
export interface BrowsePersonFilter {
|
||||
roles: Array<PersonRole>;
|
||||
query?: string;
|
||||
sortOptions?: PersonSortOptions;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
UI/Web/src/app/_models/metadata/v2/filter-v2.ts
Normal file
11
UI/Web/src/app/_models/metadata/v2/filter-v2.ts
Normal 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;
|
||||
}
|
||||
12
UI/Web/src/app/_models/metadata/v2/person-filter-field.ts
Normal file
12
UI/Web/src/app/_models/metadata/v2/person-filter-field.ts
Normal 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[];
|
||||
|
||||
9
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal file
9
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal 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[];
|
||||
|
|
@ -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;
|
||||
}
|
||||
17
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal file
17
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
}
|
||||
|
|
|
|||
25
UI/Web/src/app/_pipes/breakpoint.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/breakpoint.pipe.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
78
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal file
78
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
108
UI/Web/src/app/_pipes/generic-filter-field.pipe.ts
Normal file
108
UI/Web/src/app/_pipes/generic-filter-field.pipe.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
22
UI/Web/src/app/_resolvers/url-filter.resolver.ts
Normal file
22
UI/Web/src/app/_resolvers/url-filter.resolver.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
];
|
||||
24
UI/Web/src/app/_routes/browse-routing.module.ts
Normal file
24
UI/Web/src/app/_routes/browse-routing.module.ts
Normal 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'},
|
||||
];
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}`, {});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
||||
.main-container {
|
||||
margin-top: 10px;
|
||||
padding: 0 0 0 10px;
|
||||
128
UI/Web/src/app/browse/browse-people/browse-people.component.ts
Normal file
128
UI/Web/src/app/browse/browse-people/browse-people.component.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal file
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal 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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
67
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal file
67
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions()" [labelBy]="header()"></app-card-actionables>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span>
|
||||
{{header}}
|
||||
{{header()}}
|
||||
@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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
@use '../../../series-detail-common';
|
||||
|
||||
|
||||
|
||||
.group-item {
|
||||
background-color: transparent;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue