Merge branch 'develop' into feature/oidc
This commit is contained in:
commit
465723fedf
358 changed files with 34968 additions and 5203 deletions
35
UI/Web/src/_tag-card-common.scss
Normal file
35
UI/Web/src/_tag-card-common.scss
Normal 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);
|
||||
}
|
||||
120
UI/Web/src/app/_helpers/form-debug.ts
Normal file
120
UI/Web/src/app/_helpers/form-debug.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface ExternalMatchRateLimitErrorEvent {
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import {MatchStateOption} from "./match-state-option";
|
||||
import {LibraryType} from "../library/library";
|
||||
|
||||
export interface ManageMatchFilter {
|
||||
matchStateOption: MatchStateOption;
|
||||
libraryType: LibraryType | -1;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export enum LibraryType {
|
|||
}
|
||||
|
||||
export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images];
|
||||
export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic];
|
||||
export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel];
|
||||
|
||||
export interface Library {
|
||||
id: number;
|
||||
|
|
@ -29,6 +31,7 @@ export interface Library {
|
|||
manageReadingLists: boolean;
|
||||
allowScrobbling: boolean;
|
||||
allowMetadataMatching: boolean;
|
||||
enableMetadata: boolean;
|
||||
collapseSeriesRelationships: boolean;
|
||||
libraryFileTypes: Array<FileTypeGroup>;
|
||||
excludePatterns: Array<string>;
|
||||
|
|
|
|||
6
UI/Web/src/app/_models/metadata/browse/browse-genre.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse/browse-genre.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Genre} from "../genre";
|
||||
|
||||
export interface BrowseGenre extends Genre {
|
||||
seriesCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {Person} from "../metadata/person";
|
||||
import {Person} from "../person";
|
||||
|
||||
export interface BrowsePerson extends Person {
|
||||
seriesCount: number;
|
||||
issueCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
6
UI/Web/src/app/_models/metadata/browse/browse-tag.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import {Tag} from "../../tag";
|
||||
|
||||
export interface BrowseTag extends Tag {
|
||||
seriesCount: number;
|
||||
chapterCount: number;
|
||||
}
|
||||
|
|
@ -4,7 +4,10 @@ export interface Language {
|
|||
}
|
||||
|
||||
export interface KavitaLocale {
|
||||
fileName: string; // isoCode aka what maps to the file on disk and what transloco loads
|
||||
/**
|
||||
* isoCode aka what maps to the file on disk and what transloco loads
|
||||
*/
|
||||
fileName: string;
|
||||
renderName: string;
|
||||
translationCompletion: number;
|
||||
isRtL: boolean;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover";
|
|||
|
||||
export enum PersonRole {
|
||||
Other = 1,
|
||||
Artist = 2,
|
||||
Writer = 3,
|
||||
Penciller = 4,
|
||||
Inker = 5,
|
||||
|
|
@ -32,3 +31,22 @@ export interface Person extends IHasCover {
|
|||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes Other as it's not in use
|
||||
*/
|
||||
export const allPeopleRoles = [
|
||||
PersonRole.Writer,
|
||||
PersonRole.Penciller,
|
||||
PersonRole.Inker,
|
||||
PersonRole.Colorist,
|
||||
PersonRole.Letterer,
|
||||
PersonRole.CoverArtist,
|
||||
PersonRole.Editor,
|
||||
PersonRole.Publisher,
|
||||
PersonRole.Character,
|
||||
PersonRole.Translator,
|
||||
PersonRole.Imprint,
|
||||
PersonRole.Team,
|
||||
PersonRole.Location
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {MangaFormat} from "../manga-format";
|
||||
import {SeriesFilterV2} from "./v2/series-filter-v2";
|
||||
import {FilterV2} from "./v2/filter-v2";
|
||||
|
||||
export interface FilterItem<T> {
|
||||
title: string;
|
||||
|
|
@ -7,10 +7,6 @@ export interface FilterItem<T> {
|
|||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
sortField: SortField;
|
||||
isAscending: boolean;
|
||||
}
|
||||
|
||||
export enum SortField {
|
||||
SortName = 1,
|
||||
|
|
@ -27,7 +23,7 @@ export enum SortField {
|
|||
Random = 9
|
||||
}
|
||||
|
||||
export const allSortFields = Object.keys(SortField)
|
||||
export const allSeriesSortFields = Object.keys(SortField)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as SortField[];
|
||||
|
||||
|
|
@ -54,8 +50,8 @@ export const mangaFormatFilters = [
|
|||
}
|
||||
];
|
||||
|
||||
export interface FilterEvent {
|
||||
filterV2: SeriesFilterV2;
|
||||
export interface FilterEvent<TFilter extends number = number, TSort extends number = number> {
|
||||
filterV2: FilterV2<TFilter, TSort>;
|
||||
isFirst: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import {PersonRole} from "../person";
|
||||
import {PersonSortOptions} from "./sort-options";
|
||||
|
||||
export interface BrowsePersonFilter {
|
||||
roles: Array<PersonRole>;
|
||||
query?: string;
|
||||
sortOptions?: PersonSortOptions;
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ const enumArray = Object.keys(FilterField)
|
|||
|
||||
enumArray.sort((a, b) => a.value.localeCompare(b.value));
|
||||
|
||||
export const allFields = enumArray
|
||||
export const allSeriesFilterFields = enumArray
|
||||
.map(key => parseInt(key.key, 10))as FilterField[];
|
||||
|
||||
export const allPeople = [
|
||||
|
|
@ -66,7 +66,6 @@ export const allPeople = [
|
|||
|
||||
export const personRoleForFilterField = (role: PersonRole) => {
|
||||
switch (role) {
|
||||
case PersonRole.Artist: return FilterField.CoverArtist;
|
||||
case PersonRole.Character: return FilterField.Characters;
|
||||
case PersonRole.Colorist: return FilterField.Colorist;
|
||||
case PersonRole.CoverArtist: return FilterField.CoverArtist;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { FilterComparison } from "./filter-comparison";
|
||||
import { FilterField } from "./filter-field";
|
||||
import {FilterComparison} from "./filter-comparison";
|
||||
|
||||
export interface FilterStatement {
|
||||
export interface FilterStatement<T extends number = number> {
|
||||
comparison: FilterComparison;
|
||||
field: FilterField;
|
||||
field: T;
|
||||
value: string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
UI/Web/src/app/_models/metadata/v2/filter-v2.ts
Normal file
11
UI/Web/src/app/_models/metadata/v2/filter-v2.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import {FilterStatement} from "./filter-statement";
|
||||
import {FilterCombination} from "./filter-combination";
|
||||
import {SortOptions} from "./sort-options";
|
||||
|
||||
export interface FilterV2<TFilter extends number = number, TSort extends number = number> {
|
||||
name?: string;
|
||||
statements: Array<FilterStatement<TFilter>>;
|
||||
combination: FilterCombination;
|
||||
sortOptions?: SortOptions<TSort>;
|
||||
limitTo: number;
|
||||
}
|
||||
12
UI/Web/src/app/_models/metadata/v2/person-filter-field.ts
Normal file
12
UI/Web/src/app/_models/metadata/v2/person-filter-field.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export enum PersonFilterField {
|
||||
Role = 1,
|
||||
Name = 2,
|
||||
SeriesCount = 3,
|
||||
ChapterCount = 4,
|
||||
}
|
||||
|
||||
|
||||
export const allPersonFilterFields = Object.keys(PersonFilterField)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as PersonFilterField[];
|
||||
|
||||
9
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal file
9
UI/Web/src/app/_models/metadata/v2/person-sort-field.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export enum PersonSortField {
|
||||
Name = 1,
|
||||
SeriesCount = 2,
|
||||
ChapterCount = 3
|
||||
}
|
||||
|
||||
export const allPersonSortFields = Object.keys(PersonSortField)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10)) as PersonSortField[];
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { SortOptions } from "../series-filter";
|
||||
import {FilterStatement} from "./filter-statement";
|
||||
import {FilterCombination} from "./filter-combination";
|
||||
|
||||
export interface SeriesFilterV2 {
|
||||
name?: string;
|
||||
statements: Array<FilterStatement>;
|
||||
combination: FilterCombination;
|
||||
sortOptions?: SortOptions;
|
||||
limitTo: number;
|
||||
}
|
||||
17
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal file
17
UI/Web/src/app/_models/metadata/v2/sort-options.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import {PersonSortField} from "./person-sort-field";
|
||||
|
||||
/**
|
||||
* Series-based Sort options
|
||||
*/
|
||||
export interface SortOptions<TSort extends number = number> {
|
||||
sortField: TSort;
|
||||
isAscending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Person-based Sort Options
|
||||
*/
|
||||
export interface PersonSortOptions {
|
||||
sortField: PersonSortField;
|
||||
isAscending: boolean;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}];
|
||||
|
|
|
|||
80
UI/Web/src/app/_models/preferences/reading-profiles.ts
Normal file
80
UI/Web/src/app/_models/preferences/reading-profiles.ts
Normal 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]
|
||||
|
|
@ -7,6 +7,7 @@ export enum ScrobbleEventType {
|
|||
}
|
||||
|
||||
export interface ScrobbleEvent {
|
||||
id: number;
|
||||
seriesName: string;
|
||||
seriesId: number;
|
||||
libraryId: number;
|
||||
|
|
|
|||
|
|
@ -20,5 +20,6 @@ export enum WikiLink {
|
|||
UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native',
|
||||
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker',
|
||||
OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients',
|
||||
Guides = 'https://wiki.kavitareader.com/guides'
|
||||
Guides = 'https://wiki.kavitareader.com/guides',
|
||||
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
|
||||
}
|
||||
|
|
|
|||
25
UI/Web/src/app/_pipes/breakpoint.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/breakpoint.pipe.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {UserBreakpoint} from "../shared/_services/utility.service";
|
||||
|
||||
@Pipe({
|
||||
name: 'breakpoint'
|
||||
})
|
||||
export class BreakpointPipe implements PipeTransform {
|
||||
|
||||
transform(value: UserBreakpoint): string {
|
||||
const v = parseInt(value + '', 10) as UserBreakpoint;
|
||||
switch (v) {
|
||||
case UserBreakpoint.Never:
|
||||
return translate('breakpoint-pipe.never');
|
||||
case UserBreakpoint.Mobile:
|
||||
return translate('breakpoint-pipe.mobile');
|
||||
case UserBreakpoint.Tablet:
|
||||
return translate('breakpoint-pipe.tablet');
|
||||
case UserBreakpoint.Desktop:
|
||||
return translate('breakpoint-pipe.desktop');
|
||||
}
|
||||
throw new Error("unknown breakpoint value: " + value);
|
||||
}
|
||||
|
||||
}
|
||||
78
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal file
78
UI/Web/src/app/_pipes/browse-title.pipe.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
/**
|
||||
* Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page
|
||||
* Example: Genre & "Action" -> Browse Action
|
||||
* Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works
|
||||
*/
|
||||
@Pipe({
|
||||
name: 'browseTitle'
|
||||
})
|
||||
export class BrowseTitlePipe implements PipeTransform {
|
||||
|
||||
transform(field: FilterField, value: string): string {
|
||||
switch (field) {
|
||||
case FilterField.PublicationStatus:
|
||||
return translate('browse-title-pipe.publication-status', {value});
|
||||
case FilterField.AgeRating:
|
||||
return translate('browse-title-pipe.age-rating', {value});
|
||||
case FilterField.UserRating:
|
||||
return translate('browse-title-pipe.user-rating', {value});
|
||||
case FilterField.Tags:
|
||||
return translate('browse-title-pipe.tag', {value});
|
||||
case FilterField.Translators:
|
||||
return translate('browse-title-pipe.translator', {value});
|
||||
case FilterField.Characters:
|
||||
return translate('browse-title-pipe.character', {value});
|
||||
case FilterField.Publisher:
|
||||
return translate('browse-title-pipe.publisher', {value});
|
||||
case FilterField.Editor:
|
||||
return translate('browse-title-pipe.editor', {value});
|
||||
case FilterField.CoverArtist:
|
||||
return translate('browse-title-pipe.artist', {value});
|
||||
case FilterField.Letterer:
|
||||
return translate('browse-title-pipe.letterer', {value});
|
||||
case FilterField.Colorist:
|
||||
return translate('browse-title-pipe.colorist', {value});
|
||||
case FilterField.Inker:
|
||||
return translate('browse-title-pipe.inker', {value});
|
||||
case FilterField.Penciller:
|
||||
return translate('browse-title-pipe.penciller', {value});
|
||||
case FilterField.Writers:
|
||||
return translate('browse-title-pipe.writer', {value});
|
||||
case FilterField.Genres:
|
||||
return translate('browse-title-pipe.genre', {value});
|
||||
case FilterField.Libraries:
|
||||
return translate('browse-title-pipe.library', {value});
|
||||
case FilterField.Formats:
|
||||
return translate('browse-title-pipe.format', {value});
|
||||
case FilterField.ReleaseYear:
|
||||
return translate('browse-title-pipe.release-year', {value});
|
||||
case FilterField.Imprint:
|
||||
return translate('browse-title-pipe.imprint', {value});
|
||||
case FilterField.Team:
|
||||
return translate('browse-title-pipe.team', {value});
|
||||
case FilterField.Location:
|
||||
return translate('browse-title-pipe.location', {value});
|
||||
|
||||
// These have no natural links in the app to demand a richer title experience
|
||||
case FilterField.Languages:
|
||||
case FilterField.CollectionTags:
|
||||
case FilterField.ReadProgress:
|
||||
case FilterField.ReadTime:
|
||||
case FilterField.Path:
|
||||
case FilterField.FilePath:
|
||||
case FilterField.WantToRead:
|
||||
case FilterField.ReadingDate:
|
||||
case FilterField.AverageRating:
|
||||
case FilterField.ReadLast:
|
||||
case FilterField.Summary:
|
||||
case FilterField.SeriesName:
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
108
UI/Web/src/app/_pipes/generic-filter-field.pipe.ts
Normal file
108
UI/Web/src/app/_pipes/generic-filter-field.pipe.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
|
||||
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
|
||||
|
||||
@Pipe({
|
||||
name: 'genericFilterField'
|
||||
})
|
||||
export class GenericFilterFieldPipe implements PipeTransform {
|
||||
|
||||
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
|
||||
|
||||
switch (entityType) {
|
||||
case "series":
|
||||
return this.translateFilterField(value as FilterField);
|
||||
case "person":
|
||||
return this.translatePersonFilterField(value as PersonFilterField);
|
||||
}
|
||||
}
|
||||
|
||||
private translatePersonFilterField(value: PersonFilterField) {
|
||||
switch (value) {
|
||||
case PersonFilterField.Role:
|
||||
return translate('generic-filter-field-pipe.person-role');
|
||||
case PersonFilterField.Name:
|
||||
return translate('generic-filter-field-pipe.person-name');
|
||||
case PersonFilterField.SeriesCount:
|
||||
return translate('generic-filter-field-pipe.person-series-count');
|
||||
case PersonFilterField.ChapterCount:
|
||||
return translate('generic-filter-field-pipe.person-chapter-count');
|
||||
}
|
||||
}
|
||||
|
||||
private translateFilterField(value: FilterField) {
|
||||
switch (value) {
|
||||
case FilterField.AgeRating:
|
||||
return translate('filter-field-pipe.age-rating');
|
||||
case FilterField.Characters:
|
||||
return translate('filter-field-pipe.characters');
|
||||
case FilterField.CollectionTags:
|
||||
return translate('filter-field-pipe.collection-tags');
|
||||
case FilterField.Colorist:
|
||||
return translate('filter-field-pipe.colorist');
|
||||
case FilterField.CoverArtist:
|
||||
return translate('filter-field-pipe.cover-artist');
|
||||
case FilterField.Editor:
|
||||
return translate('filter-field-pipe.editor');
|
||||
case FilterField.Formats:
|
||||
return translate('filter-field-pipe.formats');
|
||||
case FilterField.Genres:
|
||||
return translate('filter-field-pipe.genres');
|
||||
case FilterField.Inker:
|
||||
return translate('filter-field-pipe.inker');
|
||||
case FilterField.Imprint:
|
||||
return translate('filter-field-pipe.imprint');
|
||||
case FilterField.Team:
|
||||
return translate('filter-field-pipe.team');
|
||||
case FilterField.Location:
|
||||
return translate('filter-field-pipe.location');
|
||||
case FilterField.Languages:
|
||||
return translate('filter-field-pipe.languages');
|
||||
case FilterField.Libraries:
|
||||
return translate('filter-field-pipe.libraries');
|
||||
case FilterField.Letterer:
|
||||
return translate('filter-field-pipe.letterer');
|
||||
case FilterField.PublicationStatus:
|
||||
return translate('filter-field-pipe.publication-status');
|
||||
case FilterField.Penciller:
|
||||
return translate('filter-field-pipe.penciller');
|
||||
case FilterField.Publisher:
|
||||
return translate('filter-field-pipe.publisher');
|
||||
case FilterField.ReadProgress:
|
||||
return translate('filter-field-pipe.read-progress');
|
||||
case FilterField.ReadTime:
|
||||
return translate('filter-field-pipe.read-time');
|
||||
case FilterField.ReleaseYear:
|
||||
return translate('filter-field-pipe.release-year');
|
||||
case FilterField.SeriesName:
|
||||
return translate('filter-field-pipe.series-name');
|
||||
case FilterField.Summary:
|
||||
return translate('filter-field-pipe.summary');
|
||||
case FilterField.Tags:
|
||||
return translate('filter-field-pipe.tags');
|
||||
case FilterField.Translators:
|
||||
return translate('filter-field-pipe.translators');
|
||||
case FilterField.UserRating:
|
||||
return translate('filter-field-pipe.user-rating');
|
||||
case FilterField.Writers:
|
||||
return translate('filter-field-pipe.writers');
|
||||
case FilterField.Path:
|
||||
return translate('filter-field-pipe.path');
|
||||
case FilterField.FilePath:
|
||||
return translate('filter-field-pipe.file-path');
|
||||
case FilterField.WantToRead:
|
||||
return translate('filter-field-pipe.want-to-read');
|
||||
case FilterField.ReadingDate:
|
||||
return translate('filter-field-pipe.read-date');
|
||||
case FilterField.ReadLast:
|
||||
return translate('filter-field-pipe.read-last');
|
||||
case FilterField.AverageRating:
|
||||
return translate('filter-field-pipe.average-rating');
|
||||
default:
|
||||
throw new Error(`Invalid FilterField value: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import { PersonRole } from '../_models/metadata/person';
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {PersonRole} from '../_models/metadata/person';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'personRole',
|
||||
|
|
@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform {
|
|||
|
||||
transform(value: PersonRole): string {
|
||||
switch (value) {
|
||||
case PersonRole.Artist:
|
||||
return translate('person-role-pipe.artist');
|
||||
case PersonRole.Character:
|
||||
return translate('person-role-pipe.character');
|
||||
case PersonRole.Colorist:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {SortField} from "../_models/metadata/series-filter";
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {ValidFilterEntity} from "../metadata-filter/filter-settings";
|
||||
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
|
||||
|
||||
@Pipe({
|
||||
name: 'sortField',
|
||||
|
|
@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform {
|
|||
constructor(private translocoService: TranslocoService) {
|
||||
}
|
||||
|
||||
transform(value: SortField): string {
|
||||
transform<T extends number>(value: T, entityType: ValidFilterEntity): string {
|
||||
|
||||
switch (entityType) {
|
||||
case 'series':
|
||||
return this.seriesSortFields(value as SortField);
|
||||
case 'person':
|
||||
return this.personSortFields(value as PersonSortField);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private personSortFields(value: PersonSortField) {
|
||||
switch (value) {
|
||||
case PersonSortField.Name:
|
||||
return this.translocoService.translate('sort-field-pipe.person-name');
|
||||
case PersonSortField.SeriesCount:
|
||||
return this.translocoService.translate('sort-field-pipe.person-series-count');
|
||||
case PersonSortField.ChapterCount:
|
||||
return this.translocoService.translate('sort-field-pipe.person-chapter-count');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private seriesSortFields(value: SortField) {
|
||||
switch (value) {
|
||||
case SortField.SortName:
|
||||
return this.translocoService.translate('sort-field-pipe.sort-name');
|
||||
|
|
@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform {
|
|||
case SortField.Random:
|
||||
return this.translocoService.translate('sort-field-pipe.random');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
18
UI/Web/src/app/_resolvers/reading-profile.resolver.ts
Normal file
18
UI/Web/src/app/_resolvers/reading-profile.resolver.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
UI/Web/src/app/_resolvers/url-filter.resolver.ts
Normal file
22
UI/Web/src/app/_resolvers/url-filter.resolver.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import {Injectable} from "@angular/core";
|
||||
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router";
|
||||
import {Observable, of} from "rxjs";
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service";
|
||||
|
||||
/**
|
||||
* Checks the url for a filter and resolves one if applicable, otherwise returns null.
|
||||
* It is up to the consumer to cast appropriately.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UrlFilterResolver implements Resolve<any> {
|
||||
|
||||
constructor(private filterUtilitiesService: FilterUtilitiesService) {}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FilterV2 | null> {
|
||||
if (!state.url.includes('?')) return of(null);
|
||||
return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { Routes } from "@angular/router";
|
||||
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
|
||||
import {Routes} from "@angular/router";
|
||||
import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component";
|
||||
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: AllSeriesComponent, pathMatch: 'full'},
|
||||
{path: '', component: AllSeriesComponent, pathMatch: 'full',
|
||||
runGuardsAndResolvers: 'always',
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { Routes } from "@angular/router";
|
||||
import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component";
|
||||
import {Routes} from "@angular/router";
|
||||
import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component";
|
||||
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: BookmarksComponent, pathMatch: 'full'},
|
||||
{path: '', component: BookmarksComponent, pathMatch: 'full',
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
import { Routes } from "@angular/router";
|
||||
import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component";
|
||||
import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: BrowseAuthorsComponent, pathMatch: 'full'},
|
||||
];
|
||||
24
UI/Web/src/app/_routes/browse-routing.module.ts
Normal file
24
UI/Web/src/app/_routes/browse-routing.module.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component";
|
||||
import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component";
|
||||
import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component";
|
||||
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
// Legacy route
|
||||
{path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full',
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{path: 'people', component: BrowsePeopleComponent, pathMatch: 'full',
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'},
|
||||
{path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'},
|
||||
];
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component';
|
||||
import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component';
|
||||
import {Routes} from '@angular/router';
|
||||
import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component';
|
||||
import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component';
|
||||
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: '', component: AllCollectionsComponent, pathMatch: 'full'},
|
||||
{path: ':id', component: CollectionDetailComponent},
|
||||
{path: ':id', component: CollectionDetailComponent,
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Routes } from '@angular/router';
|
||||
import { AuthGuard } from '../_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from '../_guards/library-access.guard';
|
||||
import { LibraryDetailComponent } from '../library-detail/library-detail.component';
|
||||
import {Routes} from '@angular/router';
|
||||
import {AuthGuard} from '../_guards/auth.guard';
|
||||
import {LibraryAccessGuard} from '../_guards/library-access.guard';
|
||||
import {LibraryDetailComponent} from '../library-detail/library-detail.component';
|
||||
import {UrlFilterResolver} from "../_resolvers/url-filter.resolver";
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
|
|
@ -9,12 +10,18 @@ export const routes: Routes = [
|
|||
path: ':libraryId',
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard, LibraryAccessGuard],
|
||||
component: LibraryDetailComponent
|
||||
component: LibraryDetailComponent,
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard, LibraryAccessGuard],
|
||||
component: LibraryDetailComponent
|
||||
}
|
||||
component: LibraryDetailComponent,
|
||||
resolve: {
|
||||
filter: UrlFilterResolver
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {Injectable} from '@angular/core';
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
import {environment} from "../../environments/environment";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
|
||||
@Injectable({
|
||||
|
|
@ -13,7 +12,7 @@ export class FilterService {
|
|||
baseUrl = environment.apiUrl;
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
saveFilter(filter: SeriesFilterV2) {
|
||||
saveFilter(filter: FilterV2<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'filter/update', filter);
|
||||
}
|
||||
getAllFilters() {
|
||||
|
|
@ -26,5 +25,4 @@ export class FilterService {
|
|||
renameSmartFilter(filter: SmartFilter) {
|
||||
return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {JumpKey} from '../_models/jumpbar/jump-key';
|
||||
|
||||
const keySize = 25; // Height of the JumpBar button
|
||||
|
||||
|
|
@ -105,14 +105,18 @@ export class JumpbarService {
|
|||
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
|
||||
const keys: {[key: string]: number} = {};
|
||||
data.forEach(obj => {
|
||||
let ch = keySelector(obj).charAt(0).toUpperCase();
|
||||
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
|
||||
ch = '#';
|
||||
try {
|
||||
let ch = keySelector(obj).charAt(0).toUpperCase();
|
||||
if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) {
|
||||
ch = '#';
|
||||
}
|
||||
if (!keys.hasOwnProperty(ch)) {
|
||||
keys[ch] = 0;
|
||||
}
|
||||
keys[ch] += 1;
|
||||
} catch (e) {
|
||||
console.error('Failed to calculate jump key for ', obj, e);
|
||||
}
|
||||
if (!keys.hasOwnProperty(ch)) {
|
||||
keys[ch] = 0;
|
||||
}
|
||||
keys[ch] += 1;
|
||||
});
|
||||
return Object.keys(keys).map(k => {
|
||||
k = k.toUpperCase();
|
||||
|
|
|
|||
|
|
@ -1,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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@ import {PaginatedResult} from "../_models/pagination";
|
|||
import {Series} from "../_models/series";
|
||||
import {map} from "rxjs/operators";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {BrowsePerson} from "../_models/metadata/browse/browse-person";
|
||||
import {StandaloneChapter} from "../_models/standalone-chapter";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
import {PersonFilterField} from "../_models/metadata/v2/person-filter-field";
|
||||
import {PersonSortField} from "../_models/metadata/v2/person-sort-field";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
|
@ -43,17 +46,28 @@ export class PersonService {
|
|||
return this.httpClient.get<Array<StandaloneChapter>>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`);
|
||||
}
|
||||
|
||||
getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) {
|
||||
getAuthorsToBrowse(filter: FilterV2<PersonFilterField, PersonSortField>, pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe(
|
||||
return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) {
|
||||
// let params = new HttpParams();
|
||||
// params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
//
|
||||
// return this.httpClient.post<PaginatedResult<BrowsePerson[]>>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe(
|
||||
// map((response: any) => {
|
||||
// return this.utilityService.createPaginatedResult(response) as PaginatedResult<BrowsePerson[]>;
|
||||
// })
|
||||
// );
|
||||
// }
|
||||
|
||||
downloadCover(personId: number) {
|
||||
return this.httpClient.post<string>(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ import {TextResonse} from '../_types/text-response';
|
|||
import {AccountService} from './account.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterV2} from "../_models/metadata/v2/filter-v2";
|
||||
import NoSleep from 'nosleep.js';
|
||||
import {FullProgress} from "../_models/readers/full-progress";
|
||||
import {Volume} from "../_models/volume";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
|
|
@ -107,7 +108,7 @@ export class ReaderService {
|
|||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
|
||||
getAllBookmarks(filter: SeriesFilterV2 | undefined) {
|
||||
getAllBookmarks(filter: FilterV2<FilterField> | undefined) {
|
||||
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
70
UI/Web/src/app/_services/reading-profile.service.ts
Normal file
70
UI/Web/src/app/_services/reading-profile.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@
|
|||
} @else {
|
||||
<div class="d-flex pt-3 justify-content-between">
|
||||
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
|
||||
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
||||
@if (item.series.plusMediaFormat === PlusMediaFormat.Comic) {
|
||||
<span class="me-1">{{t('issue-count', {num: item.series.chapters})}}</span>
|
||||
} @else {
|
||||
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
||||
}
|
||||
} @else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<ng-container *transloco="let t">
|
||||
<button class="btn btn-sm btn-icon" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="disabled()">
|
||||
@if (isAscending()) {
|
||||
<i class="fa fa-arrow-up" [title]="t('metadata-filter.ascending-alt')"></i>
|
||||
} @else {
|
||||
<i class="fa fa-arrow-down" [title]="t('metadata-filter.descending-alt')"></i>
|
||||
}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import {ChangeDetectionStrategy, Component, input, model} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-sort-button',
|
||||
imports: [
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './sort-button.component.html',
|
||||
styleUrl: './sort-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SortButtonComponent {
|
||||
|
||||
disabled = input<boolean>(false);
|
||||
isAscending = model<boolean>(true);
|
||||
|
||||
updateSortOrder() {
|
||||
this.isAscending.set(!this.isAscending());
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterSettings]="filterSettings"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
|
|
|||
|
|
@ -11,13 +11,12 @@ import {Title} from '@angular/platform-browser';
|
|||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {debounceTime, take} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
|
||||
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
|
||||
import {Action, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {ActionService} from 'src/app/_services/action.service';
|
||||
import {JumpbarService} from 'src/app/_services/jumpbar.service';
|
||||
|
|
@ -32,7 +31,15 @@ import {
|
|||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterV2} from "../../../_models/metadata/v2/filter-v2";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {BrowseTitlePipe} from "../../../_pipes/browse-title.pipe";
|
||||
import {MetadataService} from "../../../_services/metadata.service";
|
||||
import {Observable} from "rxjs";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
|
||||
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
||||
import {Select2Option} from "ng-select2-component";
|
||||
|
||||
|
||||
@Component({
|
||||
|
|
@ -57,18 +64,19 @@ export class AllSeriesComponent implements OnInit {
|
|||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
protected readonly metadataService = inject(MetadataService);
|
||||
|
||||
title: string = translate('side-nav.all-series');
|
||||
series: Series[] = [];
|
||||
loadingSeries = false;
|
||||
pagination: Pagination = new Pagination();
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filter: FilterV2<FilterField, SortField> | undefined = undefined;
|
||||
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActiveCheck!: FilterV2<FilterField>;
|
||||
filterActive: boolean = false;
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
|
||||
browseTitlePipe = new BrowseTitlePipe();
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
|
@ -124,13 +132,42 @@ export class AllSeriesComponent implements OnInit {
|
|||
constructor() {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||
this.filter = filter;
|
||||
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter.name || this.title;
|
||||
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
|
||||
|
||||
if (this.filter == null) {
|
||||
this.filter = this.metadataService.createDefaultFilterDto('series');
|
||||
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
|
||||
}
|
||||
|
||||
this.title = this.route.snapshot.queryParamMap.get('title') || this.filter!.name || this.title;
|
||||
this.titleService.setTitle('Kavita - ' + this.title);
|
||||
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
|
||||
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
|
||||
// To provide a richer experience, when we are browsing just a Genre/Tag/etc, we regenerate the title (if not explicitly passed) to "Browse {GenreName}"
|
||||
if (this.shouldRewriteTitle()) {
|
||||
const field = this.filter!.statements[0].field;
|
||||
|
||||
// This api returns value as string and number, it will complain without the casting
|
||||
(this.metadataService.getOptionsForFilterField<FilterField>(field, 'series') as Observable<Select2Option[]>).subscribe((opts: Select2Option[]) => {
|
||||
|
||||
const matchingOpts = opts.filter(m => `${m.value}` === `${this.filter!.statements[0].value}`);
|
||||
if (matchingOpts.length === 0) return;
|
||||
|
||||
const value = matchingOpts[0].label;
|
||||
const newTitle = this.browseTitlePipe.transform(field, value);
|
||||
if (newTitle !== '') {
|
||||
this.title = newTitle;
|
||||
this.titleService.setTitle('Kavita - ' + this.title);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
|
||||
this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
|
@ -143,7 +180,11 @@ export class AllSeriesComponent implements OnInit {
|
|||
});
|
||||
}
|
||||
|
||||
updateFilter(data: FilterEvent) {
|
||||
shouldRewriteTitle() {
|
||||
return this.title === translate('side-nav.all-series') && this.filter && this.filter.statements.length === 1 && this.filter.statements[0].comparison === FilterComparison.Equal
|
||||
}
|
||||
|
||||
updateFilter(data: FilterEvent<FilterField, SortField>) {
|
||||
if (data.filterV2 === undefined) return;
|
||||
this.filter = data.filterV2;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
|
||||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { AdminGuard } from './_guards/admin.guard';
|
||||
import {NgModule} from '@angular/core';
|
||||
import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './_guards/auth.guard';
|
||||
import {LibraryAccessGuard} from './_guards/library-access.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
|
@ -51,8 +50,8 @@ const routes: Routes = [
|
|||
loadChildren: () => import('./_routes/person-detail-routing.module').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'browse/authors',
|
||||
loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes)
|
||||
path: 'browse',
|
||||
loadChildren: () => import('./_routes/browse-routing.module').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'library',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"> {{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"> {{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"> {{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"> {{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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
|
|
@ -11,7 +18,7 @@ import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
|||
import {PageBookmark} from 'src/app/_models/readers/page-bookmark';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
|
||||
import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter';
|
||||
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {JumpbarService} from 'src/app/_services/jumpbar.service';
|
||||
|
|
@ -24,9 +31,14 @@ import {
|
|||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterV2} from "../../../_models/metadata/v2/filter-v2";
|
||||
import {Title} from "@angular/platform-browser";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
||||
import {MetadataService} from "../../../_services/metadata.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
|
|
@ -51,6 +63,8 @@ export class BookmarksComponent implements OnInit {
|
|||
private readonly titleService = inject(Title);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
public readonly imageService = inject(ImageService);
|
||||
public readonly metadataService = inject(MetadataService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly WikiLink = WikiLink;
|
||||
|
||||
|
|
@ -63,27 +77,34 @@ export class BookmarksComponent implements OnInit {
|
|||
jumpbarKeys: Array<JumpKey> = [];
|
||||
|
||||
pagination: Pagination = new Pagination();
|
||||
filter: SeriesFilterV2 | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filter: FilterV2<FilterField> | undefined = undefined;
|
||||
filterSettings: SeriesFilterSettings = new SeriesFilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActive: boolean = false;
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActiveCheck!: FilterV2<FilterField>;
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
|
||||
constructor() {
|
||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||
this.filter = filter;
|
||||
|
||||
this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter();
|
||||
this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement());
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.filter = data['filter'] as FilterV2<FilterField, SortField>;
|
||||
|
||||
if (this.filter == null) {
|
||||
this.filter = this.metadataService.createDefaultFilterDto('series');
|
||||
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
|
||||
}
|
||||
|
||||
this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series');
|
||||
this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement<FilterField>);
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
this.filterSettings.statementLimit = 1;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.titleService.setTitle('Kavita - ' + translate('bookmarks.title'));
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +211,7 @@ export class BookmarksComponent implements OnInit {
|
|||
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id));
|
||||
}
|
||||
|
||||
updateFilter(data: FilterEvent) {
|
||||
updateFilter(data: FilterEvent<FilterField, SortField>) {
|
||||
if (data.filterV2 === undefined) return;
|
||||
this.filter = data.filterV2;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {Series} from "../_models/series";
|
||||
import {Pagination} from "../_models/pagination";
|
||||
import {JumpKey} from "../_models/jumpbar/jump-key";
|
||||
import {ActivatedRoute, Router} from "@angular/router";
|
||||
import {Title} from "@angular/platform-browser";
|
||||
import {ActionFactoryService} from "../_services/action-factory.service";
|
||||
import {ActionService} from "../_services/action.service";
|
||||
import {MessageHubService} from "../_services/message-hub.service";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {PersonService} from "../_services/person.service";
|
||||
import {BrowsePerson} from "../_models/person/browse-person";
|
||||
import {JumpbarService} from "../_services/jumpbar.service";
|
||||
import {PersonCardComponent} from "../cards/person-card/person-card.component";
|
||||
import {ImageService} from "../_services/image.service";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CompactNumberPipe} from "../_pipes/compact-number.pipe";
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-browse-authors',
|
||||
imports: [
|
||||
SideNavCompanionBarComponent,
|
||||
TranslocoDirective,
|
||||
CardDetailLayoutComponent,
|
||||
DecimalPipe,
|
||||
PersonCardComponent,
|
||||
CompactNumberPipe,
|
||||
],
|
||||
templateUrl: './browse-authors.component.html',
|
||||
styleUrl: './browse-authors.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BrowseAuthorsComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly personService = inject(PersonService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
||||
|
||||
series: Series[] = [];
|
||||
isLoading = false;
|
||||
authors: Array<BrowsePerson> = [];
|
||||
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
|
||||
|
||||
ngOnInit() {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => {
|
||||
this.authors = d.result;
|
||||
this.pagination = d.pagination;
|
||||
this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
goToPerson(person: BrowsePerson) {
|
||||
this.router.navigate(['person', person.name]);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read:'browse-genres'" >
|
||||
<app-side-nav-companion-bar [hasFilter]="false">
|
||||
<h2 title>
|
||||
<span>{{t('title')}}</span>
|
||||
</h2>
|
||||
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="genres"
|
||||
[pagination]="pagination"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[refresh]="refresh"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
||||
<div class="tag-card" [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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read:'browse-authors'" >
|
||||
<app-side-nav-companion-bar [hasFilter]="false">
|
||||
<ng-container *transloco="let t; read:'browse-people'" >
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h2 title>
|
||||
<span>{{t('title')}}</span>
|
||||
</h2>
|
||||
|
|
@ -16,13 +16,16 @@
|
|||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[refresh]="refresh"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-person-card [entity]="item" [title]="item.name" [imageUrl]="imageService.getPersonImage(item.id)" (clicked)="goToPerson(item)">
|
||||
<ng-template #subtitle>
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<div style="font-size: 12px">{{item.seriesCount | compactNumber}} series</div>
|
||||
<div style="font-size: 12px">{{item.issueCount | compactNumber}} issues</div>
|
||||
<div class="tag-meta">
|
||||
<div style="font-size: 12px">{{t('series-count', {num: item.seriesCount | compactNumber})}}</div>
|
||||
<div style="font-size: 12px">{{t('issue-count', {num: item.chapterCount | compactNumber})}}</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-person-card>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@use '../../../tag-card-common';
|
||||
|
||||
.main-container {
|
||||
margin-top: 10px;
|
||||
padding: 0 0 0 10px;
|
||||
128
UI/Web/src/app/browse/browse-people/browse-people.component.ts
Normal file
128
UI/Web/src/app/browse/browse-people/browse-people.component.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject} from '@angular/core';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
|
||||
import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {Pagination} from "../../_models/pagination";
|
||||
import {JumpKey} from "../../_models/jumpbar/jump-key";
|
||||
import {ActivatedRoute, Router} from "@angular/router";
|
||||
import {PersonService} from "../../_services/person.service";
|
||||
import {BrowsePerson} from "../../_models/metadata/browse/browse-person";
|
||||
import {JumpbarService} from "../../_services/jumpbar.service";
|
||||
import {PersonCardComponent} from "../../cards/person-card/person-card.component";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
import {PersonSortField} from "../../_models/metadata/v2/person-sort-field";
|
||||
import {PersonFilterField} from "../../_models/metadata/v2/person-filter-field";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {FilterV2} from "../../_models/metadata/v2/filter-v2";
|
||||
import {PersonFilterSettings} from "../../metadata-filter/filter-settings";
|
||||
import {FilterEvent} from "../../_models/metadata/series-filter";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {FilterStatement} from "../../_models/metadata/v2/filter-statement";
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-browse-people',
|
||||
imports: [
|
||||
SideNavCompanionBarComponent,
|
||||
TranslocoDirective,
|
||||
CardDetailLayoutComponent,
|
||||
DecimalPipe,
|
||||
PersonCardComponent,
|
||||
CompactNumberPipe,
|
||||
ReactiveFormsModule,
|
||||
|
||||
],
|
||||
templateUrl: './browse-people.component.html',
|
||||
styleUrl: './browse-people.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BrowsePeopleComponent {
|
||||
protected readonly PersonSortField = PersonSortField;
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly personService = inject(PersonService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
protected readonly metadataService = inject(MetadataService);
|
||||
|
||||
isLoading = false;
|
||||
authors: Array<BrowsePerson> = [];
|
||||
pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0};
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`;
|
||||
filterSettings: PersonFilterSettings = new PersonFilterSettings();
|
||||
filterActive: boolean = false;
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filter: FilterV2<PersonFilterField, PersonSortField> | undefined = undefined;
|
||||
filterActiveCheck!: FilterV2<PersonFilterField>;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.filter = data['filter'] as FilterV2<PersonFilterField, PersonSortField>;
|
||||
|
||||
if (this.filter == null) {
|
||||
this.filter = this.metadataService.createDefaultFilterDto('person');
|
||||
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement<PersonFilterField>);
|
||||
}
|
||||
|
||||
this.filterActiveCheck = this.filterUtilityService.createPersonV2Filter();
|
||||
this.filterActiveCheck!.statements.push({value: `${PersonRole.Writer},${PersonRole.CoverArtist}`, field: PersonFilterField.Role, comparison: FilterComparison.Contains});
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
loadData() {
|
||||
if (!this.filter) {
|
||||
this.filter = this.metadataService.createDefaultFilterDto('person');
|
||||
this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement<PersonFilterField>);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
this.personService.getAuthorsToBrowse(this.filter!).subscribe(d => {
|
||||
this.authors = [...d.result];
|
||||
this.pagination = d.pagination;
|
||||
this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name);
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
goToPerson(person: BrowsePerson) {
|
||||
this.router.navigate(['person', person.name]);
|
||||
}
|
||||
|
||||
updateFilter(data: FilterEvent<PersonFilterField, PersonSortField>) {
|
||||
if (data.filterV2 === undefined) return;
|
||||
this.filter = data.filterV2;
|
||||
|
||||
if (data.isFirst) {
|
||||
this.loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => {
|
||||
this.loadData();
|
||||
});
|
||||
}
|
||||
}
|
||||
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal file
34
UI/Web/src/app/browse/browse-tags/browse-tags.component.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<div class="main-container container-fluid">
|
||||
<ng-container *transloco="let t; read:'browse-tags'" >
|
||||
<app-side-nav-companion-bar [hasFilter]="false">
|
||||
<h2 title>
|
||||
<span>{{t('title')}}</span>
|
||||
</h2>
|
||||
<h6 subtitle>{{t('genre-count', {num: pagination.totalItems | number})}} </h6>
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="tags"
|
||||
[pagination]="pagination"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[filteringDisabled]="true"
|
||||
[refresh]="refresh"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
|
||||
<div class="tag-card" [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>
|
||||
|
|
@ -0,0 +1 @@
|
|||
@use '../../../tag-card-common';
|
||||
69
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal file
69
UI/Web/src/app/browse/browse-tags/browse-tags.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<ng-container *transloco="let t; read: 'card-detail-layout'">
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
@if (header.length > 0) {
|
||||
@if (header().length > 0) {
|
||||
<div class="row mt-2 g-0 pb-2">
|
||||
<div class="col me-auto">
|
||||
<h4>
|
||||
@if (actions.length > 0) {
|
||||
@if (actions().length > 0) {
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions()" [labelBy]="header()"></app-card-actionables>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span>
|
||||
{{header}}
|
||||
{{header()}}
|
||||
@if (pagination) {
|
||||
<span class="badge bg-primary rounded-pill"
|
||||
[attr.aria-label]="t('total-items', {count: pagination.totalItems})">{{pagination.totalItems}}</span>
|
||||
|
|
@ -24,7 +24,10 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
@if (filterSettings) {
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
}
|
||||
|
||||
<div class="viewport-container ms-1" [ngClass]="{'empty': items.length === 0 && !isLoading}">
|
||||
<div class="content-container">
|
||||
<div class="card-container">
|
||||
|
|
@ -34,13 +37,14 @@
|
|||
|
||||
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="bufferAmount" [parentScroll]="parentScroll">
|
||||
<div class="grid row g-0" #container>
|
||||
<div class="card col-auto mt-2 mb-2"
|
||||
(click)="tryToSaveJumpKey()"
|
||||
*ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i"
|
||||
id="jumpbar-index--{{i}}"
|
||||
[attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
||||
</div>
|
||||
@for (item of scroll.viewPortItems; track trackByIdentity(i, item); let i = $index) {
|
||||
<div class="card col-auto mt-2 mb-2"
|
||||
(click)="tryToSaveJumpKey()"
|
||||
id="jumpbar-index--{{i}}"
|
||||
[attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</div>
|
||||
|
|
@ -54,9 +58,11 @@
|
|||
<ng-template #cardTemplate>
|
||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="bufferAmount">
|
||||
<div class="grid row g-0" #container>
|
||||
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" (click)="tryToSaveJumpKey()" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
|
|
@ -10,20 +11,22 @@ import {
|
|||
HostListener,
|
||||
inject,
|
||||
Inject,
|
||||
input,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
signal,
|
||||
Signal,
|
||||
SimpleChange,
|
||||
SimpleChanges,
|
||||
TemplateRef,
|
||||
TrackByFunction,
|
||||
ViewChild
|
||||
ViewChild,
|
||||
WritableSignal
|
||||
} from '@angular/core';
|
||||
import {NavigationStart, Router} from '@angular/router';
|
||||
import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Library} from 'src/app/_models/library/library';
|
||||
|
|
@ -35,43 +38,59 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
|
|||
import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {tap} from "rxjs";
|
||||
import {FilterV2} from "../../_models/metadata/v2/filter-v2";
|
||||
import {FilterSettingsBase, ValidFilterEntity} from "../../metadata-filter/filter-settings";
|
||||
|
||||
|
||||
const ANIMATION_TIME_MS = 0;
|
||||
|
||||
/**
|
||||
* Provides a virtualized card layout, jump bar, and metadata filter bar.
|
||||
*
|
||||
* How to use:
|
||||
* - For filtering:
|
||||
* - pass a filterSettings which will bootstrap the filtering bar
|
||||
* - pass a jumpbar method binding to calc the count for the entity (not implemented yet)
|
||||
* - For card layout
|
||||
* - Pass an identity function for trackby
|
||||
* - Pass a pagination object for the total count
|
||||
* - Pass the items
|
||||
* -
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
imports: [LoadingComponent, VirtualScrollerModule, CardActionablesComponent, MetadataFilterComponent,
|
||||
TranslocoDirective, NgTemplateOutlet, NgClass, NgForOf],
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
export class CardDetailLayoutComponent<TFilter extends number, TSort extends number> implements OnInit, OnChanges {
|
||||
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input() header: string = '';
|
||||
|
||||
header: Signal<string> = input('');
|
||||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
@Input() pagination!: Pagination;
|
||||
@Input() items: any[] = [];
|
||||
|
||||
|
||||
/**
|
||||
* Parent scroll for virtualize pagination
|
||||
*/
|
||||
@Input() parentScroll!: Element | Window;
|
||||
|
||||
// Filter Code
|
||||
// We need to pass filterOpen from the grandfather to the metadata filter due to the filter button being in a separate component
|
||||
@Input() filterOpen!: EventEmitter<boolean>;
|
||||
/**
|
||||
* Should filtering be shown on the page
|
||||
|
|
@ -80,15 +99,20 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
/**
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
actions: Signal<ActionItem<any>[]> = input([]);
|
||||
/**
|
||||
* A trackBy to help with rendering. This is required as without it there are issues when scrolling
|
||||
*/
|
||||
@Input({required: true}) trackByIdentity!: TrackByFunction<any>;
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
@Input() filterSettings: FilterSettingsBase | undefined = undefined;
|
||||
entityType = input<ValidFilterEntity | 'other'>();
|
||||
@Input() refresh!: EventEmitter<void>;
|
||||
|
||||
|
||||
/**
|
||||
* Will force the jumpbar to be disabled - in cases where you're not using a traditional filter config
|
||||
*/
|
||||
customSort = input(false);
|
||||
@Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys
|
||||
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
|
||||
|
||||
|
|
@ -101,13 +125,21 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
|
||||
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
|
||||
|
||||
filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter();
|
||||
libraries: Array<FilterItem<Library>> = [];
|
||||
|
||||
updateApplied: number = 0;
|
||||
bufferAmount: number = 1;
|
||||
|
||||
|
||||
filterSignal: WritableSignal<FilterV2<number, number> | undefined> = signal(undefined);
|
||||
hasCustomSort = computed(() => {
|
||||
if (this.customSort()) return true;
|
||||
if (this.filteringDisabled) return false;
|
||||
|
||||
const filter = this.filterSignal();
|
||||
return filter?.sortOptions?.sortField != SortField.SortName || !filter?.sortOptions.isAscending;
|
||||
});
|
||||
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||
|
||||
|
|
@ -122,16 +154,12 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
|
||||
ngOnInit(): void {
|
||||
if (this.trackByIdentity === undefined) {
|
||||
this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
|
||||
}
|
||||
|
||||
if (this.filterSettings === undefined) {
|
||||
this.filterSettings = new FilterSettings();
|
||||
this.cdRef.markForCheck();
|
||||
this.trackByIdentity = (_: number, item: any) => `${this.header()}_${this.updateApplied}_${item?.id}`;
|
||||
}
|
||||
|
||||
if (this.pagination === undefined) {
|
||||
this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1};
|
||||
const items = this.items;
|
||||
this.pagination = {currentPage: 1, itemsPerPage: items.length, totalItems: items.length, totalPages: 1};
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
|
@ -170,24 +198,16 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
}
|
||||
}
|
||||
|
||||
hasCustomSort() {
|
||||
if (this.filteringDisabled) return false;
|
||||
const hasCustomSort = this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending;
|
||||
//const hasNonDefaultSortField = this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName;
|
||||
|
||||
return hasCustomSort;
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
applyMetadataFilter(event: FilterEvent) {
|
||||
this.applyFilter.emit(event);
|
||||
applyMetadataFilter(event: FilterEvent<number, number>) {
|
||||
this.applyFilter.emit(event as FilterEvent<TFilter, TSort>);
|
||||
this.updateApplied++;
|
||||
this.filter = event.filterV2;
|
||||
this.filterSignal.set(event.filterV2);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
|
@ -208,4 +228,6 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||
tryToSaveJumpKey() {
|
||||
this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex);
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue