Merge branch 'develop' into feature/oidc

This commit is contained in:
Amelia 2025-06-29 18:20:13 +02:00
commit 465723fedf
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
358 changed files with 34968 additions and 5203 deletions

View file

@ -0,0 +1,35 @@
.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;
&.not-selectable:hover {
cursor: not-allowed;
background-color: var(--bs-card-color, #2c2c2c) !important;
}
}
.tag-card:hover {
background-color: #3a3a3a;
//transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this
}
.tag-name {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
max-height: 8rem;
height: 8rem;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-meta {
font-size: 0.85rem;
display: flex;
justify-content: space-between;
color: var(--text-muted-color, #bbb);
}

View file

@ -0,0 +1,120 @@
import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms';
interface ValidationIssue {
path: string;
controlType: string;
value: any;
errors: { [key: string]: any } | null;
status: string;
disabled: boolean;
}
export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] {
const issues: ValidationIssue[] = [];
function analyzeControl(control: AbstractControl, path: string): void {
// Determine control type for better debugging
let controlType = 'AbstractControl';
if (control instanceof FormGroup) {
controlType = 'FormGroup';
} else if (control instanceof FormArray) {
controlType = 'FormArray';
} else if (control instanceof FormControl) {
controlType = 'FormControl';
}
// Add issue if control has validation errors or is invalid
if (control.invalid || control.errors || control.disabled) {
issues.push({
path: path || 'root',
controlType,
value: control.value,
errors: control.errors,
status: control.status,
disabled: control.disabled
});
}
// Recursively check nested controls
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(key => {
const childPath = path ? `${path}.${key}` : key;
analyzeControl(control.controls[key], childPath);
});
} else if (control instanceof FormArray) {
control.controls.forEach((childControl, index) => {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
analyzeControl(childControl, childPath);
});
}
}
analyzeControl(formGroup, basePath);
return issues;
}
export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void {
const issues = analyzeFormGroupValidation(formGroup, basePath);
console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`);
console.log(`Overall Status: ${formGroup.status}`);
console.log(`Overall Valid: ${formGroup.valid}`);
console.log(`Total Issues Found: ${issues.length}`);
if (issues.length === 0) {
console.log('✅ No validation issues found!');
} else {
console.log('\n📋 Detailed Issues:');
issues.forEach((issue, index) => {
console.group(`${index + 1}. ${issue.path} (${issue.controlType})`);
console.log(`Status: ${issue.status}`);
console.log(`Value:`, issue.value);
console.log(`Disabled: ${issue.disabled}`);
if (issue.errors) {
console.log('Validation Errors:');
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
console.log(`${errorKey}:`, errorValue);
});
} else {
console.log('No specific validation errors (but control is invalid)');
}
console.groupEnd();
});
}
console.groupEnd();
}
// Alternative function that returns a formatted string instead of console logging
export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string {
const issues = analyzeFormGroupValidation(formGroup, basePath);
let report = `FormGroup Validation Report (${basePath || 'root'})\n`;
report += `Overall Status: ${formGroup.status}\n`;
report += `Overall Valid: ${formGroup.valid}\n`;
report += `Total Issues Found: ${issues.length}\n\n`;
if (issues.length === 0) {
report += '✅ No validation issues found!';
} else {
report += 'Detailed Issues:\n';
issues.forEach((issue, index) => {
report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`;
report += ` Status: ${issue.status}\n`;
report += ` Value: ${JSON.stringify(issue.value)}\n`;
report += ` Disabled: ${issue.disabled}\n`;
if (issue.errors) {
report += ' Validation Errors:\n';
Object.entries(issue.errors).forEach(([errorKey, errorValue]) => {
report += `${errorKey}: ${JSON.stringify(errorValue)}\n`;
});
} else {
report += ' No specific validation errors (but control is invalid)\n';
}
});
}
return report;
}

View file

@ -0,0 +1,4 @@
export interface ExternalMatchRateLimitErrorEvent {
seriesId: number;
seriesName: string;
}

View file

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

View file

@ -13,6 +13,8 @@ export enum LibraryType {
}
export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images];
export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic];
export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel];
export interface Library {
id: number;
@ -29,6 +31,7 @@ export interface Library {
manageReadingLists: boolean;
allowScrobbling: boolean;
allowMetadataMatching: boolean;
enableMetadata: boolean;
collapseSeriesRelationships: boolean;
libraryFileTypes: Array<FileTypeGroup>;
excludePatterns: Array<string>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { ThemeProvider } from "./site-theme";
import {ThemeProvider} from "./site-theme";
/**
* Theme for the the book reader contents
* Theme for the book reader contents
*/
export interface BookTheme {
name: string;

View file

@ -1,47 +1,7 @@
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
import {PageLayoutMode} from '../page-layout-mode';
import {PageSplitOption} from './page-split-option';
import {ReaderMode} from './reader-mode';
import {ReadingDirection} from './reading-direction';
import {ScalingOption} from './scaling-option';
import {SiteTheme} from './site-theme';
import {WritingStyle} from "./writing-style";
import {PdfTheme} from "./pdf-theme";
import {PdfScrollMode} from "./pdf-scroll-mode";
import {PdfLayoutMode} from "./pdf-layout-mode";
import {PdfSpreadMode} from "./pdf-spread-mode";
export interface Preferences {
// Manga Reader
readingDirection: ReadingDirection;
scalingOption: ScalingOption;
pageSplitOption: PageSplitOption;
readerMode: ReaderMode;
autoCloseMenu: boolean;
layoutMode: LayoutMode;
backgroundColor: string;
showScreenHints: boolean;
emulateBook: boolean;
swipeToPaginate: boolean;
allowAutomaticWebtoonReaderDetection: boolean;
// Book Reader
bookReaderMargin: number;
bookReaderLineSpacing: number;
bookReaderFontSize: number;
bookReaderFontFamily: string;
bookReaderTapToPaginate: boolean;
bookReaderReadingDirection: ReadingDirection;
bookReaderWritingStyle: WritingStyle;
bookReaderThemeName: string;
bookReaderLayoutMode: BookPageLayoutMode;
bookReaderImmersiveMode: boolean;
// PDF Reader
pdfTheme: PdfTheme;
pdfScrollMode: PdfScrollMode;
pdfSpreadMode: PdfSpreadMode;
// Global
theme: SiteTheme;
@ -58,15 +18,3 @@ export interface Preferences {
wantToReadSync: boolean;
}
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];
export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}];
export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover}
export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}];
export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}];
export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}];
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}];

View file

@ -0,0 +1,80 @@
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
import {PageLayoutMode} from '../page-layout-mode';
import {PageSplitOption} from './page-split-option';
import {ReaderMode} from './reader-mode';
import {ReadingDirection} from './reading-direction';
import {ScalingOption} from './scaling-option';
import {WritingStyle} from "./writing-style";
import {PdfTheme} from "./pdf-theme";
import {PdfScrollMode} from "./pdf-scroll-mode";
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,
User = 1,
Implicit = 2,
}
export interface ReadingProfile {
id: number;
name: string;
normalizedName: string;
kind: ReadingProfileKind;
// Manga Reader
readingDirection: ReadingDirection;
scalingOption: ScalingOption;
pageSplitOption: PageSplitOption;
readerMode: ReaderMode;
autoCloseMenu: boolean;
layoutMode: LayoutMode;
backgroundColor: string;
showScreenHints: boolean;
emulateBook: boolean;
swipeToPaginate: boolean;
allowAutomaticWebtoonReaderDetection: boolean;
widthOverride?: number;
disableWidthOverride: UserBreakpoint;
// Book Reader
bookReaderMargin: number;
bookReaderLineSpacing: number;
bookReaderFontSize: number;
bookReaderFontFamily: string;
bookReaderTapToPaginate: boolean;
bookReaderReadingDirection: ReadingDirection;
bookReaderWritingStyle: WritingStyle;
bookReaderThemeName: string;
bookReaderLayoutMode: BookPageLayoutMode;
bookReaderImmersiveMode: boolean;
// PDF Reader
pdfTheme: PdfTheme;
pdfScrollMode: PdfScrollMode;
pdfSpreadMode: PdfSpreadMode;
// relations
seriesIds: number[];
libraryIds: number[];
}
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];
export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}];
export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover}
export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}];
export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}];
export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}];
export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}];
export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}];
export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}];
export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop]

View file

@ -7,6 +7,7 @@ export enum ScrobbleEventType {
}
export interface ScrobbleEvent {
id: number;
seriesName: string;
seriesId: number;
libraryId: number;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
import {Observable} from 'rxjs';
import {ReadingProfileService} from "../_services/reading-profile.service";
@Injectable({
providedIn: 'root'
})
export class ReadingProfileResolver implements Resolve<any> {
constructor(private readingProfileService: ReadingProfileService) {}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {
// Extract seriesId from route params or parent route
const seriesId = route.params['seriesId'] || route.parent?.params['seriesId'];
return this.readingProfileService.getForSeries(seriesId);
}
}

View file

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

View file

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

View file

@ -1,10 +1,14 @@
import { Routes } from '@angular/router';
import { BookReaderComponent } from '../book-reader/_components/book-reader/book-reader.component';
import {Routes} from '@angular/router';
import {BookReaderComponent} from '../book-reader/_components/book-reader/book-reader.component';
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
export const routes: Routes = [
{
path: ':chapterId',
component: BookReaderComponent,
resolve: {
readingProfile: ReadingProfileResolver
}
}
];

View file

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

View file

@ -1,8 +0,0 @@
import { Routes } from "@angular/router";
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";
export const routes: Routes = [
{path: '', component: BrowseAuthorsComponent, pathMatch: 'full'},
];

View file

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

View file

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

View file

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

View file

@ -1,15 +1,22 @@
import { Routes } from '@angular/router';
import { MangaReaderComponent } from '../manga-reader/_components/manga-reader/manga-reader.component';
import {Routes} from '@angular/router';
import {MangaReaderComponent} from '../manga-reader/_components/manga-reader/manga-reader.component';
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
export const routes: Routes = [
{
path: ':chapterId',
component: MangaReaderComponent
component: MangaReaderComponent,
resolve: {
readingProfile: ReadingProfileResolver
}
},
{
// This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order
path: ':chapterId/list/:listId',
component: MangaReaderComponent
component: MangaReaderComponent,
resolve: {
readingProfile: ReadingProfileResolver
}
}
];

View file

@ -1,9 +1,13 @@
import { Routes } from '@angular/router';
import { PdfReaderComponent } from '../pdf-reader/_components/pdf-reader/pdf-reader.component';
import {Routes} from '@angular/router';
import {PdfReaderComponent} from '../pdf-reader/_components/pdf-reader/pdf-reader.component';
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
export const routes: Routes = [
{
path: ':chapterId',
component: PdfReaderComponent,
resolve: {
readingProfile: ReadingProfileResolver
}
}
];

View file

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

View file

@ -106,11 +106,22 @@ export class AccountService {
return true;
}
/**
* If the user has any role in the restricted roles array or is an Admin
* @param user
* @param roles
* @param restrictedRoles
*/
hasAnyRole(user: User, roles: Array<Role>, restrictedRoles: Array<Role> = []) {
if (!user || !user.roles) {
return false;
}
// If the user is an admin, they have the role
if (this.hasAdminRole(user)) {
return true;
}
// If restricted roles are provided and the user has any of them, deny access
if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) {
return false;
@ -125,6 +136,33 @@ export class AccountService {
return roles.some(role => user.roles.includes(role));
}
/**
* If User or Admin, will return false
* @param user
* @param restrictedRoles
*/
hasAnyRestrictedRole(user: User, restrictedRoles: Array<Role> = []) {
if (!user || !user.roles) {
return true;
}
if (restrictedRoles.length === 0) {
return false;
}
// If the user is an admin, they have the role
if (this.hasAdminRole(user)) {
return false;
}
if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) {
return true;
}
return false;
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);
}

View file

@ -122,6 +122,14 @@ export enum Action {
* Merge two (or more?) entities
*/
Merge = 29,
/**
* Add to a reading profile
*/
SetReadingProfile = 30,
/**
* Remove the reading profile from the entity
*/
ClearReadingProfile = 31,
}
/**
@ -342,6 +350,37 @@ export class ActionFactoryService {
requiredRoles: [Role.Admin],
children: [],
},
{
action: Action.Submenu,
title: 'reading-profiles',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.SetReadingProfile,
title: 'set-reading-profile',
description: 'set-reading-profile-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
action: Action.ClearReadingProfile,
title: 'clear-reading-profile',
description: 'clear-reading-profile-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
],
},
{
action: Action.Submenu,
title: 'others',
@ -528,7 +567,7 @@ export class ActionFactoryService {
requiresAdmin: false,
requiredRoles: [],
children: [],
},
}
],
},
{
@ -555,6 +594,37 @@ export class ActionFactoryService {
}
],
},
{
action: Action.Submenu,
title: 'reading-profiles',
description: '',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [
{
action: Action.SetReadingProfile,
title: 'set-reading-profile',
description: 'set-reading-profile-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
{
action: Action.ClearReadingProfile,
title: 'clear-reading-profile',
description: 'clear-reading-profile-tooltip',
callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false,
requiredRoles: [],
children: [],
},
],
},
{
action: Action.Submenu,
title: 'others',
@ -1047,7 +1117,10 @@ export class ActionFactoryService {
if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => {
// Ensure action children are a copy of the parent (since parent does a shallow mapping)
action.children = action.children.map(d => { return {...d}; });
action.children.forEach((childAction) => {
this.applyCallback(childAction, callback, shouldRenderFunc);
});
}
@ -1055,10 +1128,13 @@ export class ActionFactoryService {
public applyCallbackToList(list: Array<ActionItem<any>>,
callback: ActionCallback<any>,
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
// Create a clone of the list to ensure we aren't affecting the default state
const actions = list.map((a) => {
return { ...a };
});
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
return actions;
}

View file

@ -31,6 +31,9 @@ import {ChapterService} from "./chapter.service";
import {VolumeService} from "./volume.service";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component";
import {
BulkSetReadingProfileModalComponent
} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component";
export type LibraryActionCallback = (library: Partial<Library>) => void;
@ -813,4 +816,56 @@ export class ActionService {
});
}
/**
* Sets the reading profile for multiple series
* @param series
* @param callback
*/
setReadingProfileForMultiple(series: Array<Series>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id)
this.readingListModalRef.componentInstance.title = ""
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(true);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(false);
}
});
}
/**
* Sets the reading profile for multiple series
* @param library
* @param callback
*/
setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
this.readingListModalRef.componentInstance.libraryId = library.id;
this.readingListModalRef.componentInstance.title = ""
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(true);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(false);
}
});
}
}

View file

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

View file

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

View file

@ -1,15 +1,16 @@
import { Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { LibraryModifiedEvent } from '../_models/events/library-modified-event';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {Injectable} from '@angular/core';
import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr';
import {BehaviorSubject, ReplaySubject} from 'rxjs';
import {environment} from 'src/environments/environment';
import {LibraryModifiedEvent} from '../_models/events/library-modified-event';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
import {ThemeProgressEvent} from '../_models/events/theme-progress-event';
import {UserUpdateEvent} from '../_models/events/user-update-event';
import {User} from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -114,6 +115,10 @@ export enum EVENTS {
* A Person merged has been merged into another
*/
PersonMerged = 'PersonMerged',
/**
* A Rate limit error was hit when matching a series with Kavita+
*/
ExternalMatchRateLimitError = 'ExternalMatchRateLimitError'
}
export interface Message<T> {
@ -236,6 +241,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => {
this.messagesSource.next({
event: EVENTS.ExternalMatchRateLimitError,
payload: resp.body as ExternalMatchRateLimitErrorEvent
});
});
this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,

View file

@ -1,33 +1,54 @@
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http';
import {inject, Injectable} from '@angular/core';
import {tap} from 'rxjs/operators';
import {of} from 'rxjs';
import {map, of} from 'rxjs';
import {environment} from 'src/environments/environment';
import {Genre} from '../_models/metadata/genre';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
import {Language} from '../_models/metadata/language';
import {PublicationStatusDto} from '../_models/metadata/publication-status-dto';
import {Person, PersonRole} from '../_models/metadata/person';
import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person';
import {Tag} from '../_models/tag';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
import {FilterField} from '../_models/metadata/v2/filter-field';
import {SortField} from "../_models/metadata/series-filter";
import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../_models/metadata/v2/filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
import {LibraryType} from "../_models/library/library";
import {IHasCast} from "../_models/common/i-has-cast";
import {TextResonse} from "../_types/text-response";
import {QueryContext} from "../_models/metadata/v2/query-context";
import {AgeRatingPipe} from "../_pipes/age-rating.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
import {TranslocoService} from "@jsverse/transloco";
import {LibraryService} from './library.service';
import {CollectionTagService} from "./collection-tag.service";
import {PaginatedResult} from "../_models/pagination";
import {UtilityService} from "../shared/_services/utility.service";
import {BrowseGenre} from "../_models/metadata/browse/browse-genre";
import {BrowseTag} from "../_models/metadata/browse/browse-tag";
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
@Injectable({
providedIn: 'root'
})
export class MetadataService {
private readonly translocoService = inject(TranslocoService);
private readonly libraryService = inject(LibraryService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly utilityService = inject(UtilityService);
baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = [];
private ageRatingPipe = new AgeRatingPipe();
private mangaFormatPipe = new MangaFormatPipe(this.translocoService);
private personRolePipe = new PersonRolePipe();
constructor(private httpClient: HttpClient) { }
@ -74,6 +95,28 @@ export class MetadataService {
return this.httpClient.get<Array<Genre>>(this.baseUrl + method);
}
getGenreWithCounts(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowseGenre[]>>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseGenre[]>;
})
);
}
getTagWithCounts(pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<PaginatedResult<BrowseTag[]>>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowseTag[]>;
})
);
}
getAllLanguages(libraries?: Array<number>) {
let method = 'metadata/languages'
if (libraries != undefined && libraries.length > 0) {
@ -110,19 +153,28 @@ export class MetadataService {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'metadata/people-by-role?role=' + role);
}
createDefaultFilterDto(): SeriesFilterV2 {
createDefaultFilterDto<TFilter extends number, TSort extends number>(entityType: ValidFilterEntity): FilterV2<TFilter, TSort> {
return {
statements: [] as FilterStatement[],
statements: [] as FilterStatement<TFilter>[],
combination: FilterCombination.And,
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort
}
};
}
createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') {
createDefaultFilterStatement(entityType: ValidFilterEntity) {
switch (entityType) {
case 'series':
return this.createFilterStatement(FilterField.SeriesName);
case 'person':
return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`);
}
}
createFilterStatement<T extends number = number>(field: T, comparison = FilterComparison.Equal, value = '') {
return {
comparison: comparison,
field: field,
@ -130,7 +182,7 @@ export class MetadataService {
};
}
updateFilter(arr: Array<FilterStatement>, index: number, filterStmt: FilterStatement) {
updateFilter(arr: Array<FilterStatement<number>>, index: number, filterStmt: FilterStatement<number>) {
arr[index].comparison = filterStmt.comparison;
arr[index].field = filterStmt.field;
arr[index].value = filterStmt.value ? filterStmt.value + '' : '';
@ -140,8 +192,6 @@ export class MetadataService {
switch (role) {
case PersonRole.Other:
break;
case PersonRole.Artist:
break;
case PersonRole.CoverArtist:
entity.coverArtists = persons;
break;
@ -183,4 +233,85 @@ export class MetadataService {
break;
}
}
/**
* Used to get the underlying Options (for Metadata Filter Dropdowns)
* @param filterField
* @param entityType
*/
getOptionsForFilterField<T extends number>(filterField: T, entityType: ValidFilterEntity) {
switch (entityType) {
case 'series':
return this.getSeriesOptionsForFilterField(filterField as FilterField);
case 'person':
return this.getPersonOptionsForFilterField(filterField as PersonFilterField);
}
}
private getPersonOptionsForFilterField(field: PersonFilterField) {
switch (field) {
case PersonFilterField.Role:
return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}}));
}
return of([])
}
private getSeriesOptionsForFilterField(field: FilterField) {
switch (field) {
case FilterField.PublicationStatus:
return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => {
return {value: pub.value, label: pub.title}
})));
case FilterField.AgeRating:
return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)}
})));
case FilterField.Genres:
return this.getAllGenres().pipe(map(genres => genres.map(genre => {
return {value: genre.id, label: genre.title}
})));
case FilterField.Languages:
return this.getAllLanguages().pipe(map(statuses => statuses.map(status => {
return {value: status.isoCode, label: status.title + ` (${status.isoCode})`}
})));
case FilterField.Formats:
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
return {value: status.value, label: this.mangaFormatPipe.transform(status.value)}
})));
case FilterField.Libraries:
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
return {value: lib.id, label: lib.name}
})));
case FilterField.Tags:
return this.getAllTags().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.CollectionTags:
return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => {
return {value: status.id, label: status.title}
})));
case FilterField.Characters: return this.getPersonOptions(PersonRole.Character);
case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist);
case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist);
case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor);
case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker);
case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer);
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Team: return this.getPersonOptions(PersonRole.Team);
case FilterField.Location: return this.getPersonOptions(PersonRole.Location);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
}
return of([]);
}
private getPersonOptions(role: PersonRole) {
return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => {
return {value: person.id, label: person.name}
})));
}
}

View file

@ -9,6 +9,24 @@ import {AccountService} from "./account.service";
import {map} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki";
/**
* NavItem used to construct the dropdown or NavLinkModal on mobile
* Priority construction
* @param routerLink A link to a page on the web app, takes priority
* @param fragment Optional fragment for routerLink
* @param href A link to an external page, must set noopener noreferrer
* @param click Callback, lowest priority. Should only be used if routerLink and href or not set
*/
interface NavItem {
transLocoKey: string;
href?: string;
fragment?: string;
routerLink?: string;
click?: () => void;
}
@Injectable({
providedIn: 'root'
@ -21,6 +39,33 @@ export class NavService {
public localStorageSideNavKey = 'kavita--sidenav--expanded';
public navItems: NavItem[] = [
{
transLocoKey: 'all-filters',
routerLink: '/all-filters/',
},
{
transLocoKey: 'browse-genres',
routerLink: '/browse/genres',
},
{
transLocoKey: 'browse-tags',
routerLink: '/browse/tags',
},
{
transLocoKey: 'announcements',
routerLink: '/announcements/',
},
{
transLocoKey: 'help',
href: WikiLink.Guides,
},
{
transLocoKey: 'logout',
click: () => this.logout(),
}
]
private navbarVisibleSource = new ReplaySubject<boolean>(1);
/**
* If the top Nav bar is rendered or not
@ -127,6 +172,13 @@ export class NavService {
}, 10);
}
logout() {
this.accountService.logout();
this.hideNavBar();
this.hideSideNav();
this.router.navigateByUrl('/login');
}
/**
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
*/

View file

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

View file

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

View file

@ -0,0 +1,70 @@
import {inject, Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {ReadingProfile} from "../_models/preferences/reading-profiles";
@Injectable({
providedIn: 'root'
})
export class ReadingProfileService {
private readonly httpClient = inject(HttpClient);
baseUrl = environment.apiUrl;
getForSeries(seriesId: number, skipImplicit: boolean = false) {
return this.httpClient.get<ReadingProfile>(this.baseUrl + `reading-profile/${seriesId}?skipImplicit=${skipImplicit}`);
}
getForLibrary(libraryId: number) {
return this.httpClient.get<ReadingProfile | null>(this.baseUrl + `reading-profile/library?libraryId=${libraryId}`);
}
updateProfile(profile: ReadingProfile) {
return this.httpClient.post<ReadingProfile>(this.baseUrl + 'reading-profile', profile);
}
updateParentProfile(seriesId: number, profile: ReadingProfile) {
return this.httpClient.post<ReadingProfile>(this.baseUrl + `reading-profile/update-parent?seriesId=${seriesId}`, profile);
}
createProfile(profile: ReadingProfile) {
return this.httpClient.post<ReadingProfile>(this.baseUrl + 'reading-profile/create', profile);
}
promoteProfile(profileId: number) {
return this.httpClient.post<ReadingProfile>(this.baseUrl + "reading-profile/promote?profileId=" + profileId, {});
}
updateImplicit(profile: ReadingProfile, seriesId: number) {
return this.httpClient.post<ReadingProfile>(this.baseUrl + "reading-profile/series?seriesId="+seriesId, profile);
}
getAllProfiles() {
return this.httpClient.get<ReadingProfile[]>(this.baseUrl + 'reading-profile/all');
}
delete(id: number) {
return this.httpClient.delete(this.baseUrl + `reading-profile?profileId=${id}`);
}
addToSeries(id: number, seriesId: number) {
return this.httpClient.post(this.baseUrl + `reading-profile/series/${seriesId}?profileId=${id}`, {});
}
clearSeriesProfiles(seriesId: number) {
return this.httpClient.delete(this.baseUrl + `reading-profile/series/${seriesId}`, {});
}
addToLibrary(id: number, libraryId: number) {
return this.httpClient.post(this.baseUrl + `reading-profile/library/${libraryId}?profileId=${id}`, {});
}
clearLibraryProfiles(libraryId: number) {
return this.httpClient.delete(this.baseUrl + `reading-profile/library/${libraryId}`, {});
}
bulkAddToSeries(id: number, seriesIds: number[]) {
return this.httpClient.post(this.baseUrl + `reading-profile/bulk?profileId=${id}`, seriesIds);
}
}

View file

@ -104,6 +104,10 @@ export class ScrobblingService {
triggerScrobbleEventGeneration() {
return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse);
}
bulkRemoveEvents(eventIds: number[]) {
return this.httpClient.post(this.baseUrl + "scrobbling/bulk-remove-events", eventIds)
}
}

View file

@ -1,28 +1,26 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter';
import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response';
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review";
import {HttpClient, HttpParams} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {environment} from 'src/environments/environment';
import {UtilityService} from '../shared/_services/utility.service';
import {Chapter} from '../_models/chapter';
import {PaginatedResult} from '../_models/pagination';
import {Series} from '../_models/series';
import {RelatedSeries} from '../_models/series-detail/related-series';
import {SeriesDetail} from '../_models/series-detail/series-detail';
import {SeriesGroup} from '../_models/series-group';
import {SeriesMetadata} from '../_models/metadata/series-metadata';
import {Volume} from '../_models/volume';
import {TextResonse} from '../_types/text-response';
import {FilterV2} from '../_models/metadata/v2/filter-v2';
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
import {QueryContext} from "../_models/metadata/v2/query-context";
import {ExternalSeries} from "../_models/series-detail/external-series";
import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match";
import {FilterField} from "../_models/metadata/v2/filter-field";
@Injectable({
providedIn: 'root'
@ -33,10 +31,9 @@ export class SeriesService {
paginatedResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService,
private utilityService: UtilityService) { }
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) {
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>, context: QueryContext = QueryContext.None) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -48,7 +45,7 @@ export class SeriesService {
);
}
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2<FilterField>) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
@ -84,10 +81,6 @@ export class SeriesService {
return this.httpClient.post<string>(this.baseUrl + 'series/delete-multiple', {seriesIds}, TextResonse).pipe(map(s => s === "true"));
}
updateRating(seriesId: number, userRating: number) {
return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating});
}
updateSeries(model: any) {
return this.httpClient.post(this.baseUrl + 'series/update', model);
}
@ -100,7 +93,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 +109,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 +127,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 +223,4 @@ export class SeriesService {
updateDontMatch(seriesId: number, dontMatch: boolean) {
return this.httpClient.post<string>(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse);
}
}

View file

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

View file

@ -15,7 +15,7 @@
<div class="d-grid gap-2">
@for (action of currentItems; track action.title) {
@if (willRenderAction(action)) {
@if (willRenderAction(action, user!)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
(click)="handleItemClick(action)">
{{action.title}}

View file

@ -38,7 +38,7 @@ export class ActionableModalComponent implements OnInit {
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
@Input() willRenderAction!: (action: ActionItem<any>, user: User) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
@Output() actionPerformed = new EventEmitter<ActionItem<any>>();

View file

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

View file

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

View file

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

View file

@ -20,7 +20,10 @@
<form [formGroup]="formGroup">
<div class="form-group pe-1">
<label for="filter">{{t('filter-label')}}</label>
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off"/>
<div class="input-group">
<input id="filter" type="text" class="form-control" formControlName="filter" autocomplete="off" [placeholder]="t('filter-label')"/>
<button class="btn btn-primary" type="button" [disabled]="!selections.hasAnySelected()" (click)="bulkDelete()">{{t('delete-selected-label')}}</button>
</div>
</div>
</form>
</div>
@ -40,6 +43,20 @@
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
>
<ngx-datatable-column prop="select" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="selections.hasSomeSelected()">
<label for="select-all" class="form-check-label d-md-block d-none">{{t('select-all-label')}}</label>
</div>
</ng-template>
<ng-template let-event="row" let-idx="index" ngx-datatable-cell-template>
<input id="select-event-{{idx}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(event)" (change)="handleSelection(event, idx)">
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
@ -101,7 +118,7 @@
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="isPorcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ngx-datatable-column prop="isProcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}}
</ng-template>

View file

@ -1,4 +1,12 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
HostListener,
inject,
OnInit
} from '@angular/core';
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -9,7 +17,7 @@ import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-fi
import {debounceTime, take} from "rxjs/operators";
import {PaginatedResult} from "../../_models/pagination";
import {SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
@ -19,6 +27,7 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {SelectionModel} from "../../typeahead/_models/selection-model";
export interface DataTablePage {
pageNumber: number,
@ -30,7 +39,7 @@ export interface DataTablePage {
@Component({
selector: 'app-user-scrobble-history',
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe],
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -48,8 +57,6 @@ export class UserScrobbleHistoryComponent implements OnInit {
private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService);
tokenExpired = false;
formGroup: FormGroup = new FormGroup({
'filter': new FormControl('', [])
@ -68,6 +75,21 @@ export class UserScrobbleHistoryComponent implements OnInit {
};
hasRunScrobbleGen: boolean = false;
selections: SelectionModel<ScrobbleEvent> = new SelectionModel();
selectAll: boolean = false;
isShiftDown: boolean = false;
lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true;
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(_: KeyboardEvent) {
this.isShiftDown = false;
}
ngOnInit() {
this.pageInfo.pageNumber = 0;
@ -118,6 +140,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
.pipe(take(1))
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
this.events = result.result;
this.selections = new SelectionModel(false, this.events);
this.pageInfo.totalPages = result.pagination.totalPages - 1; // ngx-datatable is 0 based, Kavita is 1 based
this.pageInfo.size = result.pagination.itemsPerPage;
@ -143,4 +166,55 @@ export class UserScrobbleHistoryComponent implements OnInit {
this.toastr.info(translate('toasts.scrobble-gen-init'))
});
}
bulkDelete() {
if (!this.selections.hasAnySelected()) {
return;
}
const eventIds = this.selections.selected().map(e => e.id);
this.scrobblingService.bulkRemoveEvents(eventIds).subscribe({
next: () => {
this.events = this.events.filter(e => !eventIds.includes(e.id));
this.selectAll = false;
this.selections.clearSelected();
this.pageInfo.totalElements -= eventIds.length;
this.cdRef.markForCheck();
},
error: err => {
console.error(err);
}
});
}
toggleAll() {
this.selectAll = !this.selectAll;
this.events.forEach(e => this.selections.toggle(e, this.selectAll));
this.cdRef.markForCheck();
}
handleSelection(item: ScrobbleEvent, index: number) {
if (this.isShiftDown && this.lastSelectedIndex !== null) {
// Bulk select items between the last selected item and the current one
const start = Math.min(this.lastSelectedIndex, index);
const end = Math.max(this.lastSelectedIndex, index);
for (let i = start; i <= end; i++) {
const event = this.events[i];
if (!this.selections.isSelected(event, (e1, e2) => e1.id == e2.id)) {
this.selections.toggle(event, true);
}
}
} else {
this.selections.toggle(item);
}
this.lastSelectedIndex = index;
const numberOfSelected = this.selections.selected().length;
this.selectAll = numberOfSelected === this.events.length;
this.cdRef.markForCheck();
}
}

View file

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

View file

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {LicenseService} from "../../_services/license.service";
import {Router} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ImageComponent} from "../../shared/image/image.component";
import {ImageService} from "../../_services/image.service";
import {Series} from "../../_models/series";
@ -21,20 +21,25 @@ 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";
import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event";
import {ToastrService} from "ngx-toastr";
@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 +49,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);
@ -51,6 +57,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
private readonly manageService = inject(ManageService);
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
protected readonly imageService = inject(ImageService);
@ -58,6 +65,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
data: Array<ManageMatchSeries> = [];
filterGroup = new FormGroup({
'matchState': new FormControl(MatchStateOption.Error, []),
'libraryType': new FormControl(-1, []), // Denotes all
});
ngOnInit() {
@ -69,12 +77,19 @@ export class ManageMatchedMetadataComponent implements OnInit {
}
this.messageHub.messages$.subscribe(message => {
if (message.event !== EVENTS.ScanSeries) return;
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
if (message.event == EVENTS.ScanSeries) {
const evt = message.payload as ScanSeriesEvent;
if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) {
this.loadData();
}
}
if (message.event == EVENTS.ExternalMatchRateLimitError) {
const evt = message.payload as ExternalMatchRateLimitErrorEvent;
this.toastr.error(translate('toasts.external-match-rate-error', {seriesName: evt.seriesName}))
}
});
this.filterGroup.valueChanges.pipe(
@ -99,6 +114,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
loadData() {
const filter: ManageMatchFilter = {
matchStateOption: parseInt(this.filterGroup.get('matchState')!.value + '', 10),
libraryType: parseInt(this.filterGroup.get('libraryType')!.value + '', 10),
searchTerm: ''
};

View file

@ -9,12 +9,12 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {allEncodeFormats} from '../_models/encode-format';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {allCoverImageSizes, CoverImageSize} from '../_models/cover-image-size';
import {pageLayoutModes} from "../../_models/preferences/preferences";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {EncodeFormatPipe} from "../../_pipes/encode-format.pipe";
import {CoverImageSizePipe} from "../../_pipes/cover-image-size.pipe";
import {ConfirmService} from "../../shared/confirm.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {pageLayoutModes} from "../../_models/preferences/reading-profiles";
@Component({
selector: 'app-manage-media-settings',

View file

@ -190,7 +190,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
destinationValue: value.destinationValue,
excludeFromSource: value.excludeFromSource
}
}).filter(m => m.sourceValue.length > 0);
}).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
// Translate blacklist string -> Array<string>
return {
@ -231,15 +231,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
excludeFromSource: [mapping?.excludeFromSource || false]
});
// Autofill destination value if empty when source value loses focus
mappingGroup.get('sourceValue')?.valueChanges
.pipe(
filter(() => !mappingGroup.get('destinationValue')?.value)
)
.subscribe(sourceValue => {
mappingGroup.get('destinationValue')?.setValue(sourceValue);
});
//@ts-ignore
this.fieldMappings.push(mappingGroup);
}

View file

@ -8,7 +8,7 @@
<label for="filter" class="visually-hidden">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" type="text" class="form-control" [placeholder]="t('filter-label')" formControlName="filter" />
<button class="btn btn-primary" type="button" (click)="clear()">{{t('clear-errors')}}</button>
<button class="btn btn-primary" type="button" [disabled]="data.length === 0" (click)="clear()">{{t('clear-errors')}}</button>
</div>
</div>
</div>

View file

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

View file

@ -11,13 +11,12 @@ import {Title} from '@angular/platform-browser';
import {ActivatedRoute, Router} from '@angular/router';
import {debounceTime, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
import {Action, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service';
import {JumpbarService} from 'src/app/_services/jumpbar.service';
@ -32,7 +31,15 @@ import {
SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
import {FilterV2} from "../../../_models/metadata/v2/filter-v2";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {BrowseTitlePipe} from "../../../_pipes/browse-title.pipe";
import {MetadataService} from "../../../_services/metadata.service";
import {Observable} from "rxjs";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {Select2Option} from "ng-select2-component";
@Component({
@ -57,18 +64,19 @@ export class AllSeriesComponent implements OnInit {
private readonly jumpbarService = inject(JumpbarService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly bulkSelectionService = inject(BulkSelectionService);
protected readonly metadataService = inject(MetadataService);
title: string = translate('side-nav.all-series');
series: Series[] = [];
loadingSeries = false;
pagination: Pagination = new Pagination();
filter: SeriesFilterV2 | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filter: FilterV2<FilterField, SortField> | undefined = undefined;
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
filterOpen: EventEmitter<boolean> = new EventEmitter();
filterActiveCheck!: SeriesFilterV2;
filterActiveCheck!: FilterV2<FilterField>;
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
browseTitlePipe = new BrowseTitlePipe();
bulkActionCallback = (action: ActionItem<any>, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
@ -124,13 +132,42 @@ export class AllSeriesComponent implements OnInit {
constructor() {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter.name || this.title;
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
if (this.filter == null) {
this.filter = this.metadataService.createDefaultFilterDto('series');
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
}
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter!.name || this.title;
this.titleService.setTitle('Kavita - ' + this.title);
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
this.filterSettings.presetsV2 = this.filter;
// To provide a richer experience, when we are browsing just a Genre/Tag/etc, we regenerate the title (if not explicitly passed) to "Browse {GenreName}"
if (this.shouldRewriteTitle()) {
const field = this.filter!.statements[0].field;
// This api returns value as string and number, it will complain without the casting
(this.metadataService.getOptionsForFilterField<FilterField>(field, 'series') as Observable<Select2Option[]>).subscribe((opts: Select2Option[]) => {
const matchingOpts = opts.filter(m => `${m.value}` === `${this.filter!.statements[0].value}`);
if (matchingOpts.length === 0) return;
const value = matchingOpts[0].label;
const newTitle = this.browseTitlePipe.transform(field, value);
if (newTitle !== '') {
this.title = newTitle;
this.titleService.setTitle('Kavita - ' + this.title);
this.cdRef.markForCheck();
}
});
}
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
this.filterSettings.presetsV2 = this.filter;
this.cdRef.markForCheck();
});
@ -143,7 +180,11 @@ export class AllSeriesComponent implements OnInit {
});
}
updateFilter(data: FilterEvent) {
shouldRewriteTitle() {
return this.title === translate('side-nav.all-series') && this.filter && this.filter.statements.length === 1 && this.filter.statements[0].comparison === FilterComparison.Equal
}
updateFilter(data: FilterEvent<FilterField, SortField>) {
if (data.filterV2 === undefined) return;
this.filter = data.filterV2;

View file

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

View file

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

View file

@ -20,25 +20,26 @@
</div>
<div subheader>
<div class="pagination-cont">
<ng-container *ngIf="layoutMode !== BookPageLayoutMode.Default">
<!-- Column mode needs virtual pages -->
@if (layoutMode !== BookPageLayoutMode.Default) {
@let vp = getVirtualPage();
<div class="virt-pagination-cont">
<div class="g-0 text-center">
{{t('page-label')}}
</div>
<div class="d-flex align-items-center justify-content-between text-center row g-0" *ngIf="getVirtualPage() as vp" >
<button class="btn btn-small btn-icon col-1" (click)="prevPage()" [title]="t('prev-page')">
<div class="d-flex align-items-center justify-content-between text-center row g-0">
<button class="btn btn-small btn-icon col-1" (click)="prevPage()" [title]="t('prev-page')" [disabled]="vp[0] === 1">
<i class="fa-solid fa-caret-left" aria-hidden="true"></i>
</button>
<div class="col-1">{{vp[0]}}</div>
<div class="col-8">
<ngb-progressbar [title]="t('virtual-pages')" type="primary" height="5px" (click)="loadPage()" [value]="vp[0]" [max]="vp[1]"></ngb-progressbar>
<ngb-progressbar type="primary" height="5px" [value]="vp[0]" [max]="vp[1]"></ngb-progressbar>
</div>
<div class="col-1 btn-icon" (click)="loadPage()" [title]="t('go-to-last-page')">{{vp[1]}}</div>
<div class="col-1 btn-icon">{{vp[1]}}</div>
<button class="btn btn-small btn-icon col-1" (click)="nextPage()" [title]="t('next-page')"><i class="fa-solid fa-caret-right" aria-hidden="true"></i></button>
</div>
</div>
</ng-container>
}
<div class="g-0 text-center">
{{t('pagination-header')}}
</div>
@ -60,6 +61,8 @@
<a ngbNavLink>{{t('settings-header')}}</a>
<ng-template ngbNavContent>
<app-reader-settings
[seriesId]="seriesId"
[readingProfile]="readingProfile"
(colorThemeUpdate)="updateColorTheme($event)"
(styleUpdate)="updateReaderStyles($event)"
(clickToPaginateChanged)="showPaginationOverlay($event)"

View file

@ -277,9 +277,9 @@ $action-bar-height: 38px;
}
.virt-pagination-cont {
padding-bottom: 5px;
margin-bottom: 5px;
box-shadow: var(--drawer-pagination-horizontal-rule);
padding-bottom: 5px;
margin-bottom: 5px;
box-shadow: var(--drawer-pagination-horizontal-rule);
}
.bottom-bar {

View file

@ -21,7 +21,6 @@ import {ToastrService} from 'ngx-toastr';
import {forkJoin, fromEvent, merge, of} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators';
import {Chapter} from 'src/app/_models/chapter';
import {AccountService} from 'src/app/_services/account.service';
import {NavService} from 'src/app/_services/nav.service';
import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'src/app/_services/reader.service';
import {SeriesService} from 'src/app/_services/series.service';
@ -40,7 +39,6 @@ import {LibraryType} from 'src/app/_models/library/library';
import {BookTheme} from 'src/app/_models/preferences/book-theme';
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
import {PageStyle, ReaderSettingsComponent} from '../reader-settings/reader-settings.component';
import {User} from 'src/app/_models/user';
import {ThemeService} from 'src/app/_services/theme.service';
import {ScrollService} from 'src/app/_services/scroll.service';
import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums';
@ -63,6 +61,8 @@ import {
PersonalToCEvent
} from "../personal-table-of-contents/personal-table-of-contents.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
import {ConfirmService} from "../../../shared/confirm.service";
enum TabID {
@ -120,7 +120,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly accountService = inject(AccountService);
private readonly seriesService = inject(SeriesService);
private readonly readerService = inject(ReaderService);
private readonly renderer = inject(Renderer2);
@ -133,6 +132,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly utilityService = inject(UtilityService);
private readonly libraryService = inject(LibraryService);
private readonly themeService = inject(ThemeService);
private readonly confirmService = inject(ConfirmService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly BookPageLayoutMode = BookPageLayoutMode;
@ -146,7 +146,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
volumeId!: number;
chapterId!: number;
chapter!: Chapter;
user!: User;
readingProfile!: ReadingProfile;
/**
* Reading List id. Defaults to -1.
@ -608,7 +608,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return;
}
this.libraryId = parseInt(libraryId, 10);
this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10);
@ -621,19 +620,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
this.cdRef.markForCheck();
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
this.readingProfile = data['readingProfile'];
this.cdRef.markForCheck();
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
if (!hasProgress) {
this.toggleDrawer();
this.toastr.info(translate('toasts.book-settings-info'));
if (this.readingProfile == null) {
this.router.navigateByUrl('/home');
return;
}
});
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
this.init();
}
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
if (!hasProgress) {
this.toggleDrawer();
this.toastr.info(translate('toasts.book-settings-info'));
}
});
this.init();
});
}
@ -668,7 +671,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.chapters = results.chapters;
this.pageNum = results.progress.pageNum;
this.cdRef.markForCheck();
if (results.progress.bookScrollId) this.lastSeenScrollPartPath = results.progress.bookScrollId;
if (results.progress.bookScrollId) {
this.lastSeenScrollPartPath = results.progress.bookScrollId;
}
this.continuousChaptersStack.push(this.chapterId);
@ -730,7 +736,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
async handleKeyPress(event: KeyboardEvent) {
const activeElement = document.activeElement as HTMLElement;
const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA';
if (isInputFocused) return;
@ -748,7 +754,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
event.stopPropagation();
event.preventDefault();
} else if (event.key === KEY_CODES.G) {
this.goToPage();
await this.goToPage();
} else if (event.key === KEY_CODES.F) {
this.toggleFullscreen()
}
@ -769,6 +775,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.readerService.closeReader(this.readingListMode, this.readingListId);
}
sortElements(a: Element, b: Element) {
const aTop = a.getBoundingClientRect().top;
const bTop = b.getBoundingClientRect().top;
@ -905,33 +912,35 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
promptForPage() {
const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages - 1});
const goToPageNum = window.prompt(question, '');
async promptForPage() {
const promptConfig = {...this.confirmService.defaultPrompt};
// Pages are called sections in the UI, manga reader uses the go-to-page string so we use a different one here
promptConfig.header = translate('book-reader.go-to-section');
promptConfig.content = translate('book-reader.go-to-section-prompt', {totalSections: this.maxPages - 1});
const goToPageNum = await this.confirmService.prompt(undefined, promptConfig);
if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; }
return goToPageNum;
}
goToPage(pageNum?: number) {
async goToPage(pageNum?: number) {
let page = pageNum;
if (pageNum === null || pageNum === undefined) {
const goToPageNum = this.promptForPage();
const goToPageNum = await this.promptForPage();
if (goToPageNum === null) { return; }
page = parseInt(goToPageNum.trim(), 10);
}
if (page === undefined || this.pageNum === page) { return; }
if (page > this.maxPages) {
page = this.maxPages;
if (page > this.maxPages - 1) {
page = this.maxPages - 1;
} else if (page < 0) {
page = 0;
}
if (!(page === 0 || page === this.maxPages - 1)) {
page -= 1;
}
this.pageNum = page;
this.loadPage();
}
@ -1045,7 +1054,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// Virtual Paging stuff
this.updateWidthAndHeightCalcs();
this.updateLayoutMode(this.layoutMode || BookPageLayoutMode.Default);
this.updateLayoutMode(this.layoutMode);
this.addEmptyPageIfRequired();
// Find all the part ids and their top offset

View file

@ -1,172 +1,190 @@
<ng-container *transloco="let t; read: 'reader-settings'">
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
<form [formGroup]="settingsForm">
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
{{t('general-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="control-container" >
<div class="controls">
<div class="mb-3">
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
</select>
@if (readingProfile !== null) {
<ng-container *transloco="let t; read: 'reader-settings'">
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
<form [formGroup]="settingsForm">
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
{{t('general-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="control-container" >
<div class="controls">
<div class="mb-3">
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
</select>
</div>
</div>
</div>
<div class="row g-0 controls">
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
<div class="row g-0 controls">
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
<input type="range" class="form-range ms-2 me-2" id="fontsize" min="50" max="300" step="10" formControlName="bookReaderFontSize" [ngbTooltip]="settingsForm.get('bookReaderFontSize')?.value + '%'">
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
</span>
</div>
</div>
<div class="row g-0 controls">
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
1x
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
2.5x
<div class="row g-0 controls">
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
{{t('line-spacing-min-label')}}
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
{{t('line-spacing-max-label')}}
</span>
</div>
</div>
<div class="row g-0 controls">
<label for="margin" class="form-label col-6">{{t('margin-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
<div class="row g-0 controls">
<label for="margin" class="form-label col-6">{{t('margin-label')}}</label>
<span class="col-6 float-end" style="display: inline-flex;">
<i class="fa-solid fa-outdent"></i>
<input type="range" class="form-range ms-2 me-2" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" [ngbTooltip]="settingsForm.get('bookReaderMargin')?.value + '%'">
<i class="fa-solid fa-indent"></i>
</span>
</div>
</div>
<div class="row g-0 justify-content-between mt-2">
<button (click)="resetSettings()" class="btn btn-primary col">{{t('reset-to-defaults')}}</button>
<div class="row g-0 justify-content-between mt-2">
<button (click)="resetSettings()" class="btn btn-primary col">{{t('reset-to-defaults')}}</button>
</div>
</div>
</div>
</ng-template>
</ng-template>
</div>
</div>
</div>
</div>
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
{{t('reader-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
</button>
</div>
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
</button>
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
{{t('reader-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
<span class="phone-hidden">&nbsp;{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
</button>
</div>
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
</button>
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="tap-pagination" class="form-label">{{t('tap-to-paginate-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
<ng-template #tapPaginationTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
<span class="visually-hidden" id="tapPagination-help">
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="tap-pagination" class="form-label">{{t('tap-to-paginate-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
<ng-template #tapPaginationTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
<span class="visually-hidden" id="tapPagination-help">
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
<div class="form-check form-switch">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
</div>
</div>
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="immersive-mode" class="form-label">{{t('immersive-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
<ng-template #immersiveModeTooltip>{{t('immersive-mode-tooltip')}}</ng-template>
<span class="visually-hidden" id="immersiveMode-help">
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label for="immersive-mode" class="form-label">{{t('immersive-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
<ng-template #immersiveModeTooltip>{{t('immersive-mode-tooltip')}}</ng-template>
<span class="visually-hidden" id="immersiveMode-help">
<ng-container [ngTemplateOutlet]="immersiveModeTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
<div class="form-check form-switch">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
</div>
</div>
</div>
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="fullscreen" class="form-label">{{t('fullscreen-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top"
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
<ng-template #fullscreenTooltip>{{t('fullscreen-tooltip')}}</ng-template>
<span class="visually-hidden" id="fullscreen-help">
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
<label id="fullscreen" class="form-label">{{t('fullscreen-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top"
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
<ng-template #fullscreenTooltip>{{t('fullscreen-tooltip')}}</ng-template>
<span class="visually-hidden" id="fullscreen-help">
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
</span>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
<i class="fa {{isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
<span *ngIf="activeTheme?.isDarkTheme">&nbsp;{{isFullscreen ? t('exit') : t('enter')}}</span>
</button>
</div>
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
<i class="fa {{isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
<span *ngIf="activeTheme?.isDarkTheme">&nbsp;{{isFullscreen ? t('exit') : t('enter')}}</span>
</button>
</div>
<div class="controls">
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">{{t('layout-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
<ng-template #layoutTooltip><span [innerHTML]="t('layout-mode-tooltip')"></span></ng-template>
<span class="visually-hidden" id="layout-help">
<div class="controls">
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">{{t('layout-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
<ng-template #layoutTooltip><span [innerHTML]="t('layout-mode-tooltip')"></span></ng-template>
<span class="visually-hidden" id="layout-help">
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
</span>
<br>
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('layout-mode-label')">
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-scroll')}}</label>
<br>
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('layout-mode-label')">
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-scroll')}}</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-1col')}}</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-1col')}}</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col2">{{t('layout-mode-option-2col')}}</label>
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col2">{{t('layout-mode-option-2col')}}</label>
</div>
</div>
</div>
</ng-template>
</ng-template>
</div>
</div>
</div>
</div>
<div ngbAccordionItem id="color-panel" [title]="t('color-theme-title')" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
{{t('color-theme-title')}}
<div ngbAccordionItem id="color-panel" [title]="t('color-theme-title')" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
{{t('color-theme-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls">
<ng-container *ngFor="let theme of themes">
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
{{t(theme.translationKey)}}
</button>
</ng-container>
</div>
</ng-template>
</div>
</div>
</div>
<div class="row g-0 mt-2">
<button class="btn btn-primary col-12 mb-2"
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
(click)="updateParentPref()">
{{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
</button>
<button class="btn btn-primary col-12 mb-2"
[ngbTooltip]="t('create-new-tooltip')"
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit"
(click)="createNewProfileFromImplicit()">
{{ t('create-new') }}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="controls">
<ng-container *ngFor="let theme of themes">
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
{{t(theme.translationKey)}}
</button>
</ng-container>
</div>
</ng-template>
</div>
</div>
</div>
</div>
</form>
</ng-container>
</div>
</form>
</ng-container>
}

View file

@ -1,32 +1,46 @@
import { DOCUMENT, NgFor, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe } from '@angular/common';
import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
EventEmitter,
inject,
Inject,
Input,
OnInit,
Output
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { take } from 'rxjs';
import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode';
import { BookTheme } from 'src/app/_models/preferences/book-theme';
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
import { WritingStyle } from 'src/app/_models/preferences/writing-style';
import { ThemeProvider } from 'src/app/_models/preferences/site-theme';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { ThemeService } from 'src/app/_services/theme.service';
import { FontFamily, BookService } from '../../_services/book.service';
import { BookBlackTheme } from '../../_models/book-black-theme';
import { BookDarkTheme } from '../../_models/book-dark-theme';
import { BookWhiteTheme } from '../../_models/book-white-theme';
import { BookPaperTheme } from '../../_models/book-paper-theme';
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {skip, take} from 'rxjs';
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
import {BookTheme} from 'src/app/_models/preferences/book-theme';
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
import {User} from 'src/app/_models/user';
import {AccountService} from 'src/app/_services/account.service';
import {ThemeService} from 'src/app/_services/theme.service';
import {BookService, FontFamily} from '../../_services/book.service';
import {BookBlackTheme} from '../../_models/book-black-theme';
import {BookDarkTheme} from '../../_models/book-dark-theme';
import {BookWhiteTheme} from '../../_models/book-white-theme';
import {BookPaperTheme} from '../../_models/book-paper-theme';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective} from "@jsverse/transloco";
import {
NgbAccordionBody,
NgbAccordionButton,
NgbAccordionCollapse,
NgbAccordionDirective,
NgbAccordionHeader,
NgbAccordionItem,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
import {ToastrService} from "ngx-toastr";
/**
* Used for book reader. Do not use for other components
@ -89,9 +103,13 @@ const mobileBreakpointMarginOverride = 700;
templateUrl: './reader-settings.component.html',
styleUrls: ['./reader-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe, TranslocoDirective]
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton,
NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle,
TitleCasePipe, TranslocoDirective]
})
export class ReaderSettingsComponent implements OnInit {
@Input({required:true}) seriesId!: number;
@Input({required:true}) readingProfile!: ReadingProfile;
/**
* Outputs when clickToPaginate is changed
*/
@ -147,6 +165,11 @@ export class ReaderSettingsComponent implements OnInit {
settingsForm: FormGroup = new FormGroup({});
/**
* The reading profile itself, unless readingProfile is implicit
*/
parentReadingProfile: ReadingProfile | null = null;
/**
* System provided themes
*/
@ -166,136 +189,169 @@ export class ReaderSettingsComponent implements OnInit {
return WritingStyle;
}
constructor(private bookService: BookService, private accountService: AccountService,
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService,
private readonly cdRef: ChangeDetectorRef) {}
private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService,
private toastr: ToastrService) {}
ngOnInit(): void {
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
this.parentReadingProfile = parent;
this.cdRef.markForCheck();
})
} else {
this.parentReadingProfile = this.readingProfile;
this.cdRef.markForCheck();
}
this.fontFamilies = this.bookService.getFontFamilies();
this.fontOptions = this.fontFamilies.map(f => f.title);
this.cdRef.markForCheck();
this.setupSettings();
this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false);
this.cdRef.markForCheck();
// Emit first time so book reader gets the setting
this.readingDirection.emit(this.readingDirectionModel);
this.bookReaderWritingStyle.emit(this.writingStyleModel);
this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate);
this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode);
this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode);
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
if (this.user.preferences.bookReaderFontFamily === undefined) {
this.user.preferences.bookReaderFontFamily = 'default';
}
if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) {
this.user.preferences.bookReaderFontSize = 100;
}
if (this.user.preferences.bookReaderLineSpacing === undefined || this.user.preferences.bookReaderLineSpacing < 100) {
this.user.preferences.bookReaderLineSpacing = 100;
}
if (this.user.preferences.bookReaderMargin === undefined) {
this.user.preferences.bookReaderMargin = 0;
}
if (this.user.preferences.bookReaderReadingDirection === undefined) {
this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight;
}
if (this.user.preferences.bookReaderWritingStyle === undefined) {
this.user.preferences.bookReaderWritingStyle = WritingStyle.Horizontal;
}
this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection;
this.writingStyleModel = this.user.preferences.bookReaderWritingStyle;
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
if (familyName === 'default') {
this.pageStyles['font-family'] = 'inherit';
} else {
this.pageStyles['font-family'] = "'" + familyName + "'";
}
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['font-size'] = value + '%';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, []));
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.clickToPaginateChanged.emit(value);
});
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, []));
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['line-height'] = value + '%';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, []));
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['margin-left'] = value + 'vw';
this.pageStyles['margin-right'] = value + 'vw';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, []));
this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => {
this.layoutModeUpdate.emit(layoutMode);
});
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user.preferences.bookReaderImmersiveMode, []));
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => {
if (immersiveMode) {
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
}
this.immersiveMode.emit(immersiveMode);
});
this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme);
this.cdRef.markForCheck();
// Emit first time so book reader gets the setting
this.readingDirection.emit(this.readingDirectionModel);
this.bookReaderWritingStyle.emit(this.writingStyleModel);
this.clickToPaginateChanged.emit(this.user.preferences.bookReaderTapToPaginate);
this.layoutModeUpdate.emit(this.user.preferences.bookReaderLayoutMode);
this.immersiveMode.emit(this.user.preferences.bookReaderImmersiveMode);
this.resetSettings();
} else {
this.resetSettings();
}
// User needs to be loaded before we call this
this.resetSettings();
});
}
setupSettings() {
if (!this.readingProfile) return;
if (this.readingProfile.bookReaderFontFamily === undefined) {
this.readingProfile.bookReaderFontFamily = 'default';
}
if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) {
this.readingProfile.bookReaderFontSize = 100;
}
if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) {
this.readingProfile.bookReaderLineSpacing = 100;
}
if (this.readingProfile.bookReaderMargin === undefined) {
this.readingProfile.bookReaderMargin = 0;
}
if (this.readingProfile.bookReaderReadingDirection === undefined) {
this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight;
}
if (this.readingProfile.bookReaderWritingStyle === undefined) {
this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal;
}
this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection;
this.writingStyleModel = this.readingProfile.bookReaderWritingStyle;
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, []));
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
if (familyName === 'default') {
this.pageStyles['font-family'] = 'inherit';
} else {
this.pageStyles['font-family'] = "'" + familyName + "'";
}
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, []));
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['font-size'] = value + '%';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, []));
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.clickToPaginateChanged.emit(value);
});
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, []));
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['line-height'] = value + '%';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, []));
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
this.pageStyles['margin-left'] = value + 'vw';
this.pageStyles['margin-right'] = value + 'vw';
this.styleUpdate.emit(this.pageStyles);
});
this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, []));
this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => {
this.layoutModeUpdate.emit(layoutMode);
});
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, []));
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => {
if (immersiveMode) {
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
}
this.immersiveMode.emit(immersiveMode);
});
// Update implicit reading profile while changing settings
this.settingsForm.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot
takeUntilDestroyed(this.destroyRef),
tap(_ => this.updateImplicit())
).subscribe();
}
resetSettings() {
if (!this.readingProfile) return;
if (this.user) {
this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + 'vw', this.user.preferences.bookReaderLineSpacing + '%');
this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%');
} else {
this.setPageStyles();
}
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily);
this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize);
this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.user.preferences.bookReaderLineSpacing);
this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin);
this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection);
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate);
this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode);
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode);
this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle);
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily);
this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize);
this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing);
this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin);
this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection);
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate);
this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode);
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode);
this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle);
this.cdRef.detectChanges();
this.styleUpdate.emit(this.pageStyles);
}
updateImplicit() {
this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({
next: newProfile => {
this.readingProfile = newProfile;
this.cdRef.markForCheck();
},
error: err => {
console.error(err);
}
})
}
/**
* Internal method to be used by resetSettings. Pass items in with quantifiers
*/
@ -318,11 +374,15 @@ export class ReaderSettingsComponent implements OnInit {
};
}
setTheme(themeName: string) {
setTheme(themeName: string, update: boolean = true) {
const theme = this.themes.find(t => t.name === themeName);
this.activeTheme = theme;
this.cdRef.markForCheck();
this.colorThemeUpdate.emit(theme);
if (update) {
this.updateImplicit();
}
}
toggleReadingDirection() {
@ -334,6 +394,7 @@ export class ReaderSettingsComponent implements OnInit {
this.cdRef.markForCheck();
this.readingDirection.emit(this.readingDirectionModel);
this.updateImplicit();
}
toggleWritingStyle() {
@ -345,6 +406,7 @@ export class ReaderSettingsComponent implements OnInit {
this.cdRef.markForCheck();
this.bookReaderWritingStyle.emit(this.writingStyleModel);
this.updateImplicit();
}
toggleFullscreen() {
@ -352,4 +414,53 @@ export class ReaderSettingsComponent implements OnInit {
this.cdRef.markForCheck();
this.fullscreen.emit();
}
// menu only code
updateParentPref() {
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
return;
}
this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => {
this.readingProfile = newProfile;
this.toastr.success(translate('manga-reader.reading-profile-updated'));
this.cdRef.markForCheck();
});
}
createNewProfileFromImplicit() {
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
return;
}
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
this.readingProfile = newProfile;
this.parentReadingProfile = newProfile; // profile is no longer implicit
this.cdRef.markForCheck();
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
});
}
private packReadingProfile(): ReadingProfile {
const modelSettings = this.settingsForm.getRawValue();
const data = {...this.readingProfile!};
data.bookReaderFontFamily = modelSettings.bookReaderFontFamily;
data.bookReaderFontSize = modelSettings.bookReaderFontSize
data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing;
data.bookReaderMargin = modelSettings.bookReaderMargin;
data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate;
data.bookReaderLayoutMode = modelSettings.layoutMode;
data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode;
data.bookReaderReadingDirection = this.readingDirectionModel;
data.bookReaderWritingStyle = this.writingStyleModel;
if (this.activeTheme) {
data.bookReaderThemeName = this.activeTheme.name;
}
return data;
}
protected readonly ReadingProfileKind = ReadingProfileKind;
}

View file

@ -17,12 +17,12 @@
} @else {
@for (chapterGroup of chapters; track chapterGroup.title + chapterGroup.children.length) {
<ul class="chapter-title">
<li class="{{chapterGroup.page === pageNum ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
<li class="{{isChapterSelected(chapterGroup) ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
{{chapterGroup.title}}
</li>
@for(chapter of chapterGroup.children; track chapter.title + chapter.part) {
<ul>
<li class="{{cleanIdSelector(chapter.part) === currentPageAnchor ? 'active' : ''}}">
<li class="{{isAnchorSelected(chapter) ? 'active' : ''}}">
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
</li>
</ul>

View file

@ -3,9 +3,10 @@
&.active {
font-weight: bold;
color: var(--primary-color);
}
}
.chapter-title {
padding-inline-start: 1rem;
}
}

View file

@ -31,9 +31,8 @@ export class TableOfContentsComponent implements OnChanges {
@Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter();
ngOnChanges(changes: SimpleChanges) {
console.log('Current Page: ', this.pageNum, this.currentPageAnchor);
//console.log('Current Page: ', this.pageNum, this.currentPageAnchor);
this.cdRef.markForCheck();
}
cleanIdSelector(id: string) {
@ -47,4 +46,30 @@ export class TableOfContentsComponent implements OnChanges {
loadChapterPage(pageNum: number, part: string) {
this.loadChapter.emit({pageNum, part});
}
isChapterSelected(chapterGroup: BookChapterItem) {
if (chapterGroup.page === this.pageNum) {
return true;
}
const idx = this.chapters.indexOf(chapterGroup);
if (idx < 0) {
return false; // should never happen
}
const nextIdx = idx + 1;
// Last chapter
if (nextIdx >= this.chapters.length) {
return chapterGroup.page < this.pageNum;
}
// Passed chapter, and next chapter has not been reached
const next = this.chapters[nextIdx];
return chapterGroup.page < this.pageNum && next.page > this.pageNum;
}
isAnchorSelected(chapter: BookChapterItem) {
return this.cleanIdSelector(chapter.part) === this.currentPageAnchor
}
}

View file

@ -48,6 +48,7 @@ export const BookPaperTheme = `
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
--btn-outline-primary-text-color: black;
/* Inputs */
--input-bg-color: white;
@ -89,6 +90,8 @@ export const BookPaperTheme = `
/* Custom variables */
--theme-bg-color: #fff3c9;
--bs-secondary-bg: darkgrey;
}
.reader-container {

View file

@ -51,6 +51,7 @@ export const BookWhiteTheme = `
--btn-disabled-bg-color: #343a40;
--btn-disabled-text-color: #efefef;
--btn-disabled-border-color: #6c757d;
--btn-outline-primary-text-color: black;
/* Inputs */
--input-bg-color: white;

View file

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

View file

@ -1,87 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
OnInit
} from '@angular/core';
import {
SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe} from "@angular/common";
import {Series} from "../_models/series";
import {Pagination} from "../_models/pagination";
import {JumpKey} from "../_models/jumpbar/jump-key";
import {ActivatedRoute, Router} from "@angular/router";
import {Title} from "@angular/platform-browser";
import {ActionFactoryService} from "../_services/action-factory.service";
import {ActionService} from "../_services/action.service";
import {MessageHubService} from "../_services/message-hub.service";
import {UtilityService} from "../shared/_services/utility.service";
import {PersonService} from "../_services/person.service";
import {BrowsePerson} from "../_models/person/browse-person";
import {JumpbarService} from "../_services/jumpbar.service";
import {PersonCardComponent} from "../cards/person-card/person-card.component";
import {ImageService} from "../_services/image.service";
import {TranslocoDirective} from "@jsverse/transloco";
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
@Component({
selector: 'app-browse-authors',
imports: [
SideNavCompanionBarComponent,
TranslocoDirective,
CardDetailLayoutComponent,
DecimalPipe,
PersonCardComponent,
CompactNumberPipe,
],
templateUrl: './browse-authors.component.html',
styleUrl: './browse-authors.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BrowseAuthorsComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly titleService = inject(Title);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly hubService = inject(MessageHubService);
private readonly utilityService = inject(UtilityService);
private readonly personService = inject(PersonService);
private readonly jumpbarService = inject(JumpbarService);
protected readonly imageService = inject(ImageService);
series: Series[] = [];
isLoading = false;
authors: Array<BrowsePerson> = [];
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
refresh: EventEmitter<void> = new EventEmitter();
jumpKeys: Array<JumpKey> = [];
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
ngOnInit() {
this.isLoading = true;
this.cdRef.markForCheck();
this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => {
this.authors = d.result;
this.pagination = d.pagination;
this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
this.isLoading = false;
this.cdRef.markForCheck();
});
}
goToPerson(person: BrowsePerson) {
this.router.navigate(['person', person.name]);
}
}

View file

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

View file

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

View file

@ -0,0 +1,70 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe, NgClass} 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,
NgClass
],
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, genre: BrowseGenre) {
if (genre.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${genre.id}`).subscribe();
}
}

View file

@ -1,6 +1,6 @@
<div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-authors'" >
<app-side-nav-companion-bar [hasFilter]="false">
<ng-container *transloco="let t; read:'browse-people'" >
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title>
<span>{{t('title')}}</span>
</h2>
@ -16,13 +16,16 @@
[jumpBarKeys]="jumpKeys"
[filteringDisabled]="true"
[refresh]="refresh"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-person-card [entity]="item" [title]="item.name" [imageUrl]="imageService.getPersonImage(item.id)" (clicked)="goToPerson(item)">
<ng-template #subtitle>
<div class="d-flex justify-content-evenly">
<div style="font-size: 12px">{{item.seriesCount | compactNumber}} series</div>
<div style="font-size: 12px">{{item.issueCount | compactNumber}} issues</div>
<div class="tag-meta">
<div style="font-size: 12px">{{t('series-count', {num: item.seriesCount | compactNumber})}}</div>
<div style="font-size: 12px">{{t('issue-count', {num: item.chapterCount | compactNumber})}}</div>
</div>
</ng-template>
</app-person-card>

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,69 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
import {DecimalPipe, NgClass} 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,
NgClass
],
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, tag: BrowseTag) {
if (tag.seriesCount === 0) return; // We don't yet have an issue page
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${tag.id}`).subscribe();
}
}

View file

@ -0,0 +1,56 @@
<ng-container *transloco="let t; prefix: 'bulk-set-reading-profile-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<form [formGroup]="profileForm">
<div class="modal-body">
@if (profiles.length >= MaxItems) {
<div class="mb-3">
<label for="filter" class="form-label">{{t('filter-label')}}</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="clear()">Clear</button>
</div>
</div>
}
<ul class="list-group">
@for(profile of profiles | filter: filterList; let i = $index; track profile.name) {
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToProfile(profile)">
<div class="p-2 group-item d-flex justify-content-between align-items-center">
<div class="fw-bold">{{profile.name | sentenceCase}}</div>
@if (currentProfile && currentProfile.name === profile.name) {
<span class="pill p-1 ms-1">{{t('bound')}}</span>
}
</div>
</li>
}
@if (profiles.length === 0 && !isLoading) {
<li class="list-group-item">{{t('no-data')}}</li>
}
@if (isLoading) {
<li class="list-group-item">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</li>
}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
</div>
</form>
</ng-container>

View file

@ -0,0 +1,14 @@
.clickable:hover, .clickable:focus {
background-color: var(--list-group-hover-bg-color, --primary-color);
}
.pill {
font-size: .8rem;
background-color: var(--card-bg-color);
border-radius: 0.375rem;
color: var(--badge-text-color);
&.active {
background-color : var(--primary-color);
}
}

View file

@ -0,0 +1,120 @@
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, inject, Input, OnInit, ViewChild} from '@angular/core';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {ToastrService} from "ngx-toastr";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
@Component({
selector: 'app-bulk-set-reading-profile-modal',
imports: [
ReactiveFormsModule,
FilterPipe,
TranslocoDirective,
SentenceCasePipe
],
templateUrl: './bulk-set-reading-profile-modal.component.html',
styleUrl: './bulk-set-reading-profile-modal.component.scss'
})
export class BulkSetReadingProfileModalComponent implements OnInit, AfterViewInit {
private readonly modal = inject(NgbActiveModal);
private readonly readingProfileService = inject(ReadingProfileService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly MaxItems = 8;
/**
* Modal Header - since this code is used for multiple flows
*/
@Input({required: true}) title!: string;
/**
* Series Ids to add to Reading Profile
*/
@Input() seriesIds: Array<number> = [];
@Input() libraryId: number | undefined;
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
currentProfile: ReadingProfile | null = null;
profiles: Array<ReadingProfile> = [];
isLoading: boolean = false;
profileForm: FormGroup = new FormGroup({
filterQuery: new FormControl('', []), // Used for inline filtering when too many RPs
});
ngOnInit(): void {
this.profileForm.addControl('title', new FormControl(this.title, []));
this.isLoading = true;
this.cdRef.markForCheck();
if (this.libraryId !== undefined) {
this.readingProfileService.getForLibrary(this.libraryId).subscribe(profile => {
this.currentProfile = profile;
this.cdRef.markForCheck();
});
} else if (this.seriesIds.length === 1) {
this.readingProfileService.getForSeries(this.seriesIds[0], true).subscribe(profile => {
this.currentProfile = profile;
this.cdRef.markForCheck();
});
}
this.readingProfileService.getAllProfiles().subscribe(profiles => {
this.profiles = profiles;
this.isLoading = false;
this.cdRef.markForCheck();
});
}
ngAfterViewInit() {
// Shift focus to input
if (this.inputElem) {
this.inputElem.nativeElement.select();
this.cdRef.markForCheck();
}
}
close() {
this.modal.close();
}
addToProfile(profile: ReadingProfile) {
if (this.seriesIds.length == 1) {
this.readingProfileService.addToSeries(profile.id, this.seriesIds[0]).subscribe(() => {
this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name}));
this.modal.close();
});
return;
}
if (this.seriesIds.length > 1) {
this.readingProfileService.bulkAddToSeries(profile.id, this.seriesIds).subscribe(() => {
this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name}));
this.modal.close();
});
return;
}
if (this.libraryId) {
this.readingProfileService.addToLibrary(profile.id, this.libraryId).subscribe(() => {
this.toastr.success(translate('toasts.library-bound-to-reading-profile', {name: profile.name}));
this.modal.close();
});
}
}
filterList = (listItem: ReadingProfile) => {
return listItem.name.toLowerCase().indexOf((this.profileForm.value.filterQuery || '').toLowerCase()) >= 0;
}
clear() {
this.profileForm.get('filterQuery')?.setValue('');
}
protected readonly ReadingProfileKind = ReadingProfileKind;
}

View file

@ -144,7 +144,7 @@ export class BulkSelectionService {
*/
getActions(callback: (action: ActionItem<any>, data: any) => void) {
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection,
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList, Action.SetReadingProfile];
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions);

View file

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

View file

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

View file

@ -94,7 +94,7 @@
<span class="card-actions">
@if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
<app-card-actionables [entity]="actionEntity" (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
}
</span>
</div>

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