UX Overhaul Part 1 (#3047)

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
Robbie Davis 2024-08-09 13:55:31 -04:00 committed by GitHub
parent 5934d516f3
commit ff79710ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
324 changed files with 11589 additions and 4598 deletions

View file

@ -504,7 +504,6 @@
"version": "17.3.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
"dev": true,
"dependencies": {
"@babel/core": "7.23.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -532,7 +531,6 @@
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -561,14 +559,12 @@
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -749,7 +745,6 @@
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -778,14 +773,12 @@
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -5629,7 +5622,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -5642,7 +5634,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -5914,7 +5905,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
@ -6226,7 +6216,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -6518,8 +6507,7 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/cookie": {
"version": "0.6.0",
@ -7421,7 +7409,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -7431,7 +7418,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8540,7 +8526,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -9222,7 +9207,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -11063,7 +11047,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -12453,7 +12436,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -12465,7 +12447,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -12476,8 +12457,7 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"node_modules/regenerate": {
"version": "1.4.2",
@ -12945,7 +12925,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"devOptional": true
},
"node_modules/sass": {
"version": "1.71.1",
@ -13064,7 +13044,6 @@
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@ -13079,7 +13058,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -13090,8 +13068,7 @@
"node_modules/semver/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/send": {
"version": "0.18.0",
@ -14222,7 +14199,6 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -79,4 +79,7 @@ export interface Chapter {
translators: Array<Person>;
teams: Array<Person>;
locations: Array<Person>;
primaryColor?: string;
secondaryColor?: string;
}

View file

@ -31,6 +31,8 @@ export interface ReadingList {
* If this is empty or null, the cover image isn't set. Do not use this externally.
*/
coverImage: string;
primaryColor?: string;
secondaryColor?: string;
startingYear: number;
startingMonth: number;
endingYear: number;

View file

@ -64,4 +64,7 @@ export interface Series {
* This is currently only used on Series detail page for recommendations
*/
summary?: string;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
}

View file

@ -0,0 +1,4 @@
export interface ColorScape {
primary?: string;
secondary?: string;
}

View file

@ -18,4 +18,8 @@ export interface Volume {
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
coverImage?: string;
primaryColor: string;
secondaryColor: string;
}

View file

@ -18,5 +18,7 @@ export enum WikiLink {
ScannerExclude = 'https://wiki.kavitareader.com/guides/admin-settings/libraries#exclude-patterns',
Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries',
UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native',
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker'
UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker',
OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients',
Guides = 'https://wiki.kavitareader.com/guides'
}

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
@Pipe({
name: 'bookPageLayoutMode',
standalone: true
})
export class BookPageLayoutModePipe implements PipeTransform {
transform(value: BookPageLayoutMode): string {
switch (value) {
case BookPageLayoutMode.Column1: return translate('preferences.1-column');
case BookPageLayoutMode.Column2: return translate('preferences.2-column');
case BookPageLayoutMode.Default: return translate('preferences.scroll');
}
}
}

View file

@ -0,0 +1,25 @@
import {Pipe, PipeTransform} from '@angular/core';
import {CoverImageSize} from "../admin/_models/cover-image-size";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'coverImageSize',
standalone: true
})
export class CoverImageSizePipe implements PipeTransform {
transform(value: CoverImageSize): string {
switch (value) {
case CoverImageSize.Default:
return translate('cover-image-size.default');
case CoverImageSize.Medium:
return translate('cover-image-size.medium');
case CoverImageSize.Large:
return translate('cover-image-size.large');
case CoverImageSize.XLarge:
return translate('cover-image-size.xlarge');
}
}
}

View file

@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core';
import {EncodeFormat} from "../admin/_models/encode-format";
@Pipe({
name: 'encodeFormat',
standalone: true
})
export class EncodeFormatPipe implements PipeTransform {
transform(value: EncodeFormat): string {
switch (value) {
case EncodeFormat.PNG:
return 'PNG';
case EncodeFormat.WebP:
return 'WebP';
case EncodeFormat.AVIF:
return 'AVIF';
}
}
}

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {LayoutMode} from "../manga-reader/_models/layout-mode";
@Pipe({
name: 'layoutMode',
standalone: true
})
export class LayoutModePipe implements PipeTransform {
transform(value: LayoutMode): string {
switch (value) {
case LayoutMode.Single: return translate('preferences.single');
case LayoutMode.Double: return translate('preferences.double');
case LayoutMode.DoubleReversed: return translate('preferences.double-manga');
case LayoutMode.DoubleNoCover: return translate('preferences.double-no-cover');
}
}
}

View file

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PageLayoutMode} from "../_models/page-layout-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pageLayoutMode',
standalone: true
})
export class PageLayoutModePipe implements PipeTransform {
transform(value: PageLayoutMode): string {
switch (value) {
case PageLayoutMode.Cards: return translate('preferences.cards');
case PageLayoutMode.List: return translate('preferences.list');
}
}
}

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {PageSplitOption} from "../_models/preferences/page-split-option";
@Pipe({
name: 'pageSplitOption',
standalone: true
})
export class PageSplitOptionPipe implements PipeTransform {
transform(value: PageSplitOption): string {
switch (value) {
case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen');
case PageSplitOption.NoSplit: return translate('preferences.no-split');
case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right');
case PageSplitOption.SplitRightToLeft: return translate('preferences.split-right-to-left');
}
}
}

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode";
@Pipe({
name: 'pdfScrollMode',
standalone: true
})
export class PdfScrollModePipe implements PipeTransform {
transform(value: PdfScrollMode): string {
switch (value) {
case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple');
case PdfScrollMode.Page: return translate('preferences.pdf-page');
case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal');
case PdfScrollMode.Vertical: return translate('preferences.pdf-vertical');
}
}
}

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pdfSpreadMode',
standalone: true
})
export class PdfSpreadModePipe implements PipeTransform {
transform(value: PdfSpreadMode): string {
switch (value) {
case PdfSpreadMode.None: return translate('preferences.pdf-none');
case PdfSpreadMode.Odd: return translate('preferences.pdf-odd');
case PdfSpreadMode.Even: return translate('preferences.pdf-even');
}
}
}

View file

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {PdfTheme} from "../_models/preferences/pdf-theme";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'pdfTheme',
standalone: true
})
export class PdfThemePipe implements PipeTransform {
transform(value: PdfTheme): string {
switch (value) {
case PdfTheme.Dark: return translate('preferences.pdf-dark');
case PdfTheme.Light: return translate('preferences.pdf-light');
}
}
}

View file

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ReadingDirection} from "../_models/preferences/reading-direction";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'readingDirection',
standalone: true
})
export class ReadingDirectionPipe implements PipeTransform {
transform(value: ReadingDirection): string {
switch (value) {
case ReadingDirection.LeftToRight: return translate('preferences.left-to-right');
case ReadingDirection.RightToLeft: return translate('preferences.right-to-right');
}
}
}

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import {ReaderMode} from "../_models/preferences/reader-mode";
import {translate} from "@ngneat/transloco";
@Pipe({
name: 'readerMode',
standalone: true
})
export class ReaderModePipe implements PipeTransform {
transform(value: ReaderMode): string {
switch (value) {
case ReaderMode.UpDown: return translate('preferences.up-down');
case ReaderMode.Webtoon: return translate('preferences.webtoon');
case ReaderMode.LeftRight: return translate('preferences.left-to-right');
}
}
}

View file

@ -0,0 +1,20 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {ScalingOption} from "../_models/preferences/scaling-option";
@Pipe({
name: 'scalingOption',
standalone: true
})
export class ScalingOptionPipe implements PipeTransform {
transform(value: ScalingOption): string {
switch (value) {
case ScalingOption.Automatic: return translate('preferences.automatic');
case ScalingOption.FitToHeight: return translate('preferences.fit-to-height');
case ScalingOption.FitToWidth: return translate('preferences.fit-to-width');
case ScalingOption.Original: return translate('preferences.original');
}
}
}

View file

@ -0,0 +1,19 @@
import {Pipe, PipeTransform} from '@angular/core';
import {ScrobbleProvider} from "../_services/scrobbling.service";
@Pipe({
name: 'scrobbleProviderName',
standalone: true
})
export class ScrobbleProviderNamePipe implements PipeTransform {
transform(value: ScrobbleProvider): string {
switch (value) {
case ScrobbleProvider.AniList: return 'AniList';
case ScrobbleProvider.Mal: return 'MAL';
case ScrobbleProvider.Kavita: return 'Kavita';
case ScrobbleProvider.GoogleBooks: return 'Google Books';
}
}
}

View file

@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {translate} from "@ngneat/transloco";
/**
* Translates the fragment for Settings to a User title
*/
@Pipe({
name: 'settingFragment',
standalone: true
})
export class SettingFragmentPipe implements PipeTransform {
transform(tabID: SettingsTabId | string): string {
return translate('settings.' + tabID);
}
}

View file

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
import {translate} from "@ngneat/transloco";
import {WritingStyle} from "../_models/preferences/writing-style";
@Pipe({
name: 'writingStyle',
standalone: true
})
export class WritingStylePipe implements PipeTransform {
transform(value: WritingStyle): string {
switch (value) {
case WritingStyle.Horizontal: return translate('preferences.horizontal');
case WritingStyle.Vertical: return translate('preferences.vertical');
}
}
}

View file

@ -1,16 +0,0 @@
import { Routes } from '@angular/router';
import { AdminGuard } from '../_guards/admin.guard';
import { DashboardComponent } from '../admin/dashboard/dashboard.component';
export const routes: Routes = [
{path: '**', component: DashboardComponent, pathMatch: 'full', canActivate: [AdminGuard]},
{
path: '',
runGuardsAndResolvers: 'always',
canActivate: [AdminGuard],
children: [
{path: 'dashboard', component: DashboardComponent},
]
}
];

View file

@ -0,0 +1,6 @@
import { Routes } from '@angular/router';
import {SettingsComponent} from "../settings/_components/settings/settings.component";
export const routes: Routes = [
{path: '', component: SettingsComponent, pathMatch: 'full'},
];

View file

@ -1,6 +0,0 @@
import { Routes } from '@angular/router';
import { UserPreferencesComponent } from '../user-settings/user-preferences/user-preferences.component';
export const routes: Routes = [
{path: '', component: UserPreferencesComponent, pathMatch: 'full'},
];

View file

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import {DestroyRef, inject, Injectable } from '@angular/core';
import {catchError, of, ReplaySubject, throwError} from 'rxjs';
import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Preferences } from '../_models/preferences/preferences';
@ -42,6 +42,10 @@ export class AccountService {
// Stores values, when someone subscribes gives (1) of last values seen.
private currentUserSource = new ReplaySubject<User | undefined>(1);
public currentUser$ = this.currentUserSource.asObservable();
public isAdmin$: Observable<boolean> = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
if (!u) return false;
return this.hasAdminRole(u);
}), shareReplay({bufferSize: 1, refCount: true}));
private hasValidLicenseSource = new ReplaySubject<boolean>(1);
/**
@ -74,6 +78,17 @@ export class AccountService {
});
}
hasAnyRole(user: User, roles: Array<Role>) {
if (!user || !user.roles) {
return false;
}
if (roles.length === 0) {
return true;
}
return roles.some(role => user.roles.includes(role));
}
hasAdminRole(user: User) {
return user && user.roles.includes(Role.Admin);
}

View file

@ -102,7 +102,11 @@ export enum Action {
* Promotes the underlying item (Reading List, Collection)
*/
Promote = 24,
UnPromote = 25
UnPromote = 25,
/**
* Invoke a refresh covers as false to generate colorscapes
*/
GenerateColorScape = 26,
}
/**
@ -245,6 +249,13 @@ export class ActionFactoryService {
requiresAdmin: true,
children: [],
},
{
action: Action.GenerateColorScape,
title: 'generate-colorscape',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.AnalyzeFiles,
title: 'analyze-files',

View file

@ -84,23 +84,25 @@ export class ActionService implements OnDestroy {
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @param forceUpdate Optional Should we force
* @returns
*/
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback) {
async refreshMetadata(library: Partial<Library>, callback?: LibraryActionCallback, forceUpdate: boolean = true) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
if (callback) {
callback(library);
// Prompt the user if we are doing a forced call
if (forceUpdate) {
if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) {
if (callback) {
callback(library);
}
return;
}
return;
}
const forceUpdate = true; //await this.promptIfForce();
this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => {
this.libraryService.refreshMetadata(library?.id, forceUpdate).subscribe((res: any) => {
this.toastr.info(translate('toasts.scan-queued', {name: library.name}));
if (callback) {
callback(library);
@ -467,7 +469,7 @@ export class ActionService implements OnDestroy {
this.readingListModalRef.componentInstance.seriesId = seriesId;
this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id);
this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id);
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple;
@ -507,7 +509,7 @@ export class ActionService implements OnDestroy {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id);
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
this.readingListModalRef.componentInstance.title = translate('action.multiple-selections');
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series;
@ -535,7 +537,7 @@ export class ActionService implements OnDestroy {
if (this.collectionModalRef != null) { return; }
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' });
this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id);
this.collectionModalRef.componentInstance.title = 'New Collection';
this.collectionModalRef.componentInstance.title = translate('action.new-collection');
this.collectionModalRef.closed.pipe(take(1)).subscribe(() => {
this.collectionModalRef = null;

View file

@ -0,0 +1,386 @@
import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
interface ColorSpace {
primary: string;
lighter: string;
darker: string;
complementary: string;
}
interface ColorSpaceRGBA {
primary: RGBAColor;
lighter: RGBAColor;
darker: RGBAColor;
complementary: RGBAColor;
}
interface RGBAColor {
r: number;
g: number;
b: number;
a: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
const colorScapeSelector = 'colorscape';
/**
* ColorScape handles setting the scape and managing the transitions
*/
@Injectable({
providedIn: 'root'
})
export class ColorscapeService {
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
public colors$ = this.colorSubject.asObservable();
private minDuration = 1000; // minimum duration
private maxDuration = 4000; // maximum duration
constructor(@Inject(DOCUMENT) private document: Document) {
}
/**
* Sets a color scape for the active theme
* @param primaryColor
* @param complementaryColor
*/
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
if (this.getCssVariable('--colorscape-enabled') === 'false') {
return;
}
const elem = this.document.querySelector('#backgroundCanvas');
if (!elem) {
return;
}
const newColors: ColorSpace = primaryColor ?
this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) :
this.defaultColors();
const newColorsRGBA = this.convertColorsToRGBA(newColors);
const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors());
const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA);
// Check if the colors we are transitioning to are visually equal
if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) {
return;
}
this.animateColorTransition(oldColors, newColorsRGBA, duration);
this.colorSubject.next(newColorsRGBA);
}
private areColorSpacesVisuallyEqual(color1: ColorSpaceRGBA, color2: ColorSpaceRGBA, threshold: number = 0): boolean {
return this.areRGBAColorsVisuallyEqual(color1.primary, color2.primary, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.lighter, color2.lighter, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.darker, color2.darker, threshold) &&
this.areRGBAColorsVisuallyEqual(color1.complementary, color2.complementary, threshold);
}
private areRGBAColorsVisuallyEqual(color1: RGBAColor, color2: RGBAColor, threshold: number = 0): boolean {
return Math.abs(color1.r - color2.r) <= threshold &&
Math.abs(color1.g - color2.g) <= threshold &&
Math.abs(color1.b - color2.b) <= threshold &&
Math.abs(color1.a - color2.a) <= threshold / 255;
}
private convertColorsToRGBA(colors: ColorSpace): ColorSpaceRGBA {
return {
primary: this.parseColorToRGBA(colors.primary),
lighter: this.parseColorToRGBA(colors.lighter),
darker: this.parseColorToRGBA(colors.darker),
complementary: this.parseColorToRGBA(colors.complementary)
};
}
private parseColorToRGBA(color: string): RGBAColor {
if (color.startsWith('#')) {
return this.hexToRGBA(color);
} else if (color.startsWith('rgb')) {
return this.rgbStringToRGBA(color);
} else {
console.warn(`Unsupported color format: ${color}. Defaulting to black.`);
return { r: 0, g: 0, b: 0, a: 1 };
}
}
private hexToRGBA(hex: string, opacity: number = 1): RGBAColor {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
a: opacity
}
: { r: 0, g: 0, b: 0, a: opacity };
}
private rgbStringToRGBA(rgb: string): RGBAColor {
const matches = rgb.match(/(\d+(\.\d+)?)/g);
if (matches) {
return {
r: parseInt(matches[0], 10),
g: parseInt(matches[1], 10),
b: parseInt(matches[2], 10),
a: matches.length === 4 ? parseFloat(matches[3]) : 1
};
}
return { r: 0, g: 0, b: 0, a: 1 };
}
private calculateTransitionDuration(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA): number {
const colorKeys: (keyof ColorSpaceRGBA)[] = ['primary', 'lighter', 'darker', 'complementary'];
let totalDistance = 0;
for (const key of colorKeys) {
const oldRGB = this.rgbaToRgb(oldColors[key]);
const newRGB = this.rgbaToRgb(newColors[key]);
totalDistance += this.calculateColorDistance(oldRGB, newRGB);
}
// Normalize the total distance and map it to our duration range
const normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4
const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration);
return Math.round(duration);
}
private rgbaToRgb(rgba: RGBAColor): RGB {
return { r: rgba.r, g: rgba.g, b: rgba.b };
}
private calculateColorDistance(rgb1: RGB, rgb2: RGB): number {
return Math.sqrt(
Math.pow(rgb2.r - rgb1.r, 2) +
Math.pow(rgb2.g - rgb1.g, 2) +
Math.pow(rgb2.b - rgb1.b, 2)
);
}
private defaultColors() {
return {
primary: this.getCssVariable('--colorscape-primary-default-color'),
lighter: this.getCssVariable('--colorscape-lighter-default-color'),
darker: this.getCssVariable('--colorscape-darker-default-color'),
complementary: this.getCssVariable('--colorscape-complementary-default-color'),
}
}
private animateColorTransition(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA, duration: number) {
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsedTime = currentTime - startTime;
const progress = Math.min(elapsedTime / duration, 1);
const interpolatedColors: ColorSpaceRGBA = {
primary: this.interpolateRGBAColor(oldColors.primary, newColors.primary, progress),
lighter: this.interpolateRGBAColor(oldColors.lighter, newColors.lighter, progress),
darker: this.interpolateRGBAColor(oldColors.darker, newColors.darker, progress),
complementary: this.interpolateRGBAColor(oldColors.complementary, newColors.complementary, progress)
};
this.setColorsImmediately(interpolatedColors);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor {
return {
r: Math.round(color1.r + (color2.r - color1.r) * progress),
g: Math.round(color1.g + (color2.g - color1.g) * progress),
b: Math.round(color1.b + (color2.b - color1.b) * progress),
a: color1.a + (color2.a - color1.a) * progress
};
}
private setColorsImmediately(colors: ColorSpaceRGBA) {
this.injectStyleElement(colorScapeSelector, `
:root, :root .default {
--colorscape-primary-color: ${this.rgbaToString(colors.primary)};
--colorscape-lighter-color: ${this.rgbaToString(colors.lighter)};
--colorscape-darker-color: ${this.rgbaToString(colors.darker)};
--colorscape-complementary-color: ${this.rgbaToString(colors.complementary)};
--colorscape-primary-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
--colorscape-lighter-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
--colorscape-darker-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
--colorscape-complementary-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })};
}
`);
}
private generateBackgroundColors(primaryColor: string, secondaryColor: string | null = null, isDarkTheme: boolean = true): ColorSpace {
const primary = this.hexToRgb(primaryColor);
const secondary = secondaryColor ? this.hexToRgb(secondaryColor) : this.calculateComplementaryRgb(primary);
const primaryHSL = this.rgbToHsl(primary);
const secondaryHSL = this.rgbToHsl(secondary);
if (isDarkTheme) {
const lighterHSL = this.adjustHue(secondaryHSL, 30);
lighterHSL.s = Math.min(lighterHSL.s + 0.2, 1);
lighterHSL.l = Math.min(lighterHSL.l + 0.1, 0.6);
const darkerHSL = { ...primaryHSL };
darkerHSL.l = Math.max(darkerHSL.l - 0.3, 0.1);
const complementaryHSL = this.adjustHue(primaryHSL, 180);
complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1);
complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2);
return {
primary: this.rgbToHex(primary),
lighter: this.rgbToHex(this.hslToRgb(lighterHSL)),
darker: this.rgbToHex(this.hslToRgb(darkerHSL)),
complementary: this.rgbToHex(this.hslToRgb(complementaryHSL))
};
} else {
// NOTE: Light themes look bad in general with this system.
const lighterHSL = { ...primaryHSL };
lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0);
lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95);
const darkerHSL = { ...primaryHSL };
darkerHSL.s = Math.max(darkerHSL.s - 0.1, 0);
darkerHSL.l = Math.min(darkerHSL.l + 0.3, 0.9);
const complementaryHSL = this.adjustHue(primaryHSL, 180);
complementaryHSL.s = Math.max(complementaryHSL.s - 0.2, 0);
complementaryHSL.l = Math.min(complementaryHSL.l + 0.4, 0.9);
return {
primary: this.rgbToHex(primary),
lighter: this.rgbToHex(this.hslToRgb(lighterHSL)),
darker: this.rgbToHex(this.hslToRgb(darkerHSL)),
complementary: this.rgbToHex(this.hslToRgb(complementaryHSL))
};
}
}
private hexToRgb(hex: string): RGB {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
private rgbToHex(rgb: RGB): string {
return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`;
}
private rgbToHsl(rgb: RGB): { h: number; s: number; l: number } {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h, s, l };
}
private hslToRgb(hsl: { h: number; s: number; l: number }): RGB {
const { h, s, l } = hsl;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
private adjustHue(hsl: { h: number; s: number; l: number }, amount: number): { h: number; s: number; l: number } {
return {
h: (hsl.h + amount / 360) % 1,
s: hsl.s,
l: hsl.l
};
}
private calculateComplementaryRgb(rgb: RGB): RGB {
const hsl = this.rgbToHsl(rgb);
const complementaryHsl = this.adjustHue(hsl, 180);
return this.hslToRgb(complementaryHsl);
}
private rgbaToString(color: RGBAColor): string {
return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
}
private getCssVariable(variableName: string): string {
return getComputedStyle(this.document.body).getPropertyValue(variableName).trim();
}
private isDarkTheme(): boolean {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim().toLowerCase() === 'dark';
}
private injectStyleElement(id: string, styles: string) {
let styleElement = this.document.getElementById(id);
if (!styleElement) {
styleElement = this.document.createElement('style');
styleElement.id = id;
this.document.head.appendChild(styleElement);
}
styleElement.textContent = styles;
}
private unsetPageColorOverrides() {
Array.from(this.document.head.children).filter(el => el.tagName === 'STYLE' && el.id.toLowerCase() === colorScapeSelector).forEach(c => this.document.head.removeChild(c));
}
}

View file

@ -33,11 +33,11 @@ export class DeviceService {
}
createDevice(name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse);
return this.httpClient.post<Device>(this.baseUrl + 'device/create', {name, platform, emailAddress});
}
updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) {
return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse);
return this.httpClient.post<Device>(this.baseUrl + 'device/update', {id, name, platform, emailAddress});
}
deleteDevice(id: number) {

View file

@ -20,8 +20,8 @@ export class JumpbarService {
return '';
}
getResumePosition(key: string) {
if (this.resumeScroll.hasOwnProperty(key)) return this.resumeScroll[key];
getResumePosition(url: string) {
if (this.resumeScroll.hasOwnProperty(url)) return this.resumeScroll[url];
return 0;
}
@ -29,8 +29,8 @@ export class JumpbarService {
this.resumeKeys[key] = value;
}
saveScrollOffset(key: string, value: number) {
this.resumeScroll[key] = value;
saveResumePosition(url: string, value: number) {
this.resumeScroll[url] = value;
}
generateJumpBar(jumpBarKeys: Array<JumpKey>, currentSize: number) {
@ -93,10 +93,10 @@ export class JumpbarService {
}
/**
*
*
* @param data An array of objects
* @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey
* @returns
* @returns
*/
getJumpKeys(data :Array<any>, keySelector: (data: any) => string) {
const keys: {[key: string]: number} = {};

View file

@ -1,18 +1,25 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { ReplaySubject, take } from 'rxjs';
import {DestroyRef, inject, Inject, Injectable, OnDestroy, Renderer2, RendererFactory2} from '@angular/core';
import {filter, ReplaySubject, Subject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
import {AccountService} from "./account.service";
import {tap} from "rxjs/operators";
import {map, tap} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
})
export class NavService {
private readonly accountService = inject(AccountService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
public localStorageSideNavKey = 'kavita--sidenav--expanded';
private navbarVisibleSource = new ReplaySubject<boolean>(1);
@ -33,10 +40,22 @@ export class NavService {
*/
sideNavVisibility$ = this.sideNavVisibilitySource.asObservable();
usePreferenceSideNav$ = this.router.events.pipe(
filter(event => event instanceof NavigationEnd),
map((evt) => {
const event = (evt as NavigationEnd);
const url = event.urlAfterRedirects || event.url;
return (
/\/admin\/dashboard(#.*)?/.test(url) || /\/preferences(\/[^\/]+|#.*)?/.test(url) || /\/settings(\/[^\/]+|#.*)?/.test(url)
);
}),
takeUntilDestroyed(this.destroyRef),
);
private renderer: Renderer2;
baseUrl = environment.apiUrl;
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient, private accountService: AccountService) {
constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) {
this.renderer = rendererFactory.createRenderer(null, null);
// To avoid flashing, let's check if we are authenticated before we show
@ -79,9 +98,9 @@ export class NavService {
* Shows the top nav bar. This should be visible on all pages except the reader.
*/
showNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px');
this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - 56px)');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - 56px)');
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', 'var(--nav-offset)');
this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.navbarVisibleSource.next(true);
}
@ -117,4 +136,9 @@ export class NavService {
localStorage.setItem(this.localStorageSideNavKey, newVal + '');
});
}
collapseSideNav(state: boolean) {
this.sideNavCollapseSource.next(state);
localStorage.setItem(this.localStorageSideNavKey, state + '');
}
}

View file

@ -1,9 +1,17 @@
import {DOCUMENT} from '@angular/common';
import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
import {
DestroyRef,
inject,
Inject,
Injectable,
Renderer2,
RendererFactory2,
SecurityContext
} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
import {ToastrService} from 'ngx-toastr';
import {map, ReplaySubject, take} from 'rxjs';
import {filter, map, ReplaySubject, take, tap} from 'rxjs';
import {environment} from 'src/environments/environment';
import {ConfirmService} from '../shared/confirm.service';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
@ -15,7 +23,9 @@ import {translate} from "@ngneat/transloco";
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
import {NgxFileDropEntry} from "ngx-file-drop";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
import {NavigationEnd, Router} from "@angular/router";
import {ColorscapeService} from "./colorscape.service";
import {ColorScape} from "../_models/theme/colorscape";
@Injectable({
providedIn: 'root'
@ -23,6 +33,8 @@ import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"
export class ThemeService {
private readonly destroyRef = inject(DestroyRef);
private readonly colorTransitionService = inject(ColorscapeService);
public defaultTheme: string = 'dark';
public defaultBookTheme: string = 'Dark';
@ -42,9 +54,16 @@ export class ThemeService {
constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient,
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) {
messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService,
private router: Router) {
this.renderer = rendererFactory.createRenderer(null, null);
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe(() => {
this.setColorScape('');
});
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
if (message.event === EVENTS.NotificationProgress) {
@ -90,21 +109,21 @@ export class ThemeService {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
}
/**
* --theme-color from theme. Updates the meta tag
* @returns
*/
getThemeColor() {
return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim();
}
/**
* --theme-color from theme. Updates the meta tag
* @returns
*/
getThemeColor() {
return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim();
}
/**
* --msapplication-TileColor from theme. Updates the meta tag
* @returns
*/
getTileColor() {
return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim();
}
/**
* --msapplication-TileColor from theme. Updates the meta tag
* @returns
*/
getTileColor() {
return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim();
}
getCssVariable(variable: string) {
return getComputedStyle(this.document.body).getPropertyValue(variable).trim();
@ -166,6 +185,26 @@ export class ThemeService {
}
/**
* Set's the background color from a single primary color.
* @param primaryColor
* @param complementaryColor
*/
setColorScape(primaryColor: string, complementaryColor: string | null = null) {
this.colorTransitionService.setColorScape(primaryColor, complementaryColor);
}
/**
* Trigger a request to get the colors for a given entity and apply them
* @param entity
* @param id
*/
refreshColorScape(entity: 'series' | 'volume' | 'chapter', id: number) {
return this.httpClient.get<ColorScape>(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => {
this.setColorScape(cs.primary || '', cs.secondary);
}));
}
/**
* Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body
* @param themeName
@ -187,7 +226,6 @@ export class ThemeService {
const styleElem = this.document.createElement('style');
styleElem.id = 'theme-' + theme.name;
styleElem.appendChild(this.document.createTextNode(content));
this.renderer.appendChild(this.document.head, styleElem);
// Check if the theme has --theme-color and apply it to meta tag
@ -238,6 +276,4 @@ export class ThemeService {
private unsetBookThemes() {
Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c));
}
}

View file

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'actionable'">
<ng-container *ngIf="actions.length > 0">
@if (actions.length > 0) {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
@ -8,33 +8,33 @@
</div>
</div>
<ng-template #submenu let-list="list">
<ng-container *ngFor="let action of list">
@for(action of list; track action.id) {
<!-- Non Submenu items -->
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined ; else submenuDropdown">
<ng-container *ngIf="action.dynamicList !== undefined && (action.dynamicList | async | dynamicList) as dList; else justItem">
<ng-container *ngFor="let dynamicItem of dList">
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@for(dynamicItem of dList; track dynamicItem.title) {
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
</ng-container>
</ng-container>
<ng-template #justItem>
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
</ng-template>
</ng-container>
<ng-template #submenuDropdown>
<!-- Submenu items -->
<ng-container *ngIf="shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)">
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left" (click)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
<button *ngIf="willRenderAction(action)" id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
}
} @else if (willRenderAction(action)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right-top"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)">
@if (willRenderAction(action)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div>
</ng-container>
</ng-template>
</ng-container>
}
}
}
</ng-template>
</ng-container>
}
</ng-container>

View file

@ -26,3 +26,9 @@
float: right;
padding: var(--bs-dropdown-item-padding-y) 0;
}
// Robbie added this but it broke most of the uses
//.dropdown-toggle {
// padding-top: 0;
// padding-bottom: 0;
//}

View file

@ -11,7 +11,7 @@ import {
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {CommonModule} from "@angular/common";
import {AsyncPipe, CommonModule, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@ngneat/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -19,7 +19,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-card-actionables',
standalone: true,
imports: [CommonModule, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective],
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -1,5 +1,5 @@
.profile-image {
font-size: 2rem;
font-size: 1.2rem;
padding: 20px;
}

View file

@ -21,12 +21,9 @@
}
</div>
</div>
<table class="table table-striped table-hover table-sm scrollable">
<table class="table table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="createdUtc" (sort)="updateSort($event)">
{{t('created-header')}}
</th>
<th scope="col" sortable="lastModifiedUtc" (sort)="updateSort($event)" direction="desc">
{{t('last-modified-header')}}
</th>
@ -45,66 +42,62 @@
</tr>
</thead>
<tbody>
@if (events.length === 0) {
<tr>
<td colspan="6">{{t('no-data')}}</td>
</tr>
}
<tr *ngFor="let item of events; let idx = index;">
<td>
{{item.createdUtc | utcToLocalTime | defaultValue}}
</td>
<td>
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
@for(item of events; track item; let idx = $index) {
<tr>
<td>
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}
</td>
<td id="scrobble-history--{{idx}}">
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
</td>
<td>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
</td>
<td>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</td>
<td>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</td>
</tr>
} @empty {
<tr><td colspan="6" style="text-align: center;">{{t('no-data')}}</td></tr>
}
</tbody>
</table>
</ng-container>

View file

@ -4,7 +4,7 @@ import {CommonModule} from '@angular/common';
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe";
import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter";
import {debounceTime, take} from "rxjs/operators";

View file

@ -14,12 +14,8 @@ $breadcrumb-divider: quote(">");
border: 1px solid #ced4da;
}
.table {
background-color: lightgrey;
}
.disabled {
color: lightgrey !important;
cursor: not-allowed !important;
background-color: var(--error-color);
}
}

View file

@ -3,10 +3,10 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import {NgFor, NgIf} from '@angular/common';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {TranslocoDirective} from "@ngneat/transloco";
import {SelectionModel} from "../../../typeahead/_models/selection-model";
@Component({
selector: 'app-library-access-modal',

View file

@ -1,3 +1,4 @@
export enum CoverImageSize {
Default = 1,
Medium = 2,
@ -5,10 +6,6 @@ export enum CoverImageSize {
XLarge = 4
}
export const CoverImageSizes =
[
{value: CoverImageSize.Default, title: 'cover-image-size.default'},
{value: CoverImageSize.Medium, title: 'cover-image-size.medium'},
{value: CoverImageSize.Large, title: 'cover-image-size.large'},
{value: CoverImageSize.XLarge, title: 'cover-image-size.xlarge'}
];
export const allCoverImageSizes = Object.keys(CoverImageSize)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as CoverImageSize[];

View file

@ -4,4 +4,6 @@ export enum EncodeFormat {
AVIF = 2
}
export const EncodeFormats = [{value: EncodeFormat.PNG, title: 'PNG'}, {value: EncodeFormat.WebP, title: 'WebP'}, {value: EncodeFormat.AVIF, title: 'AVIF'}];
export const allEncodeFormats = Object.keys(EncodeFormat)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as EncodeFormat[];

View file

@ -3,4 +3,5 @@ export interface KavitaMediaError {
filePath: string;
comment: string;
details: string;
createdUtc: string;
}

View file

@ -1,48 +0,0 @@
<ng-container *transloco="let t; read: 'admin-dashboard'">
<app-side-nav-companion-bar>
<h2 title>
{{t('title')}}
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class=tab>
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ t(tab.title) }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === TabID.General">
<app-manage-settings></app-manage-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Email">
<app-manage-email-settings></app-manage-email-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Media">
<app-manage-media-settings></app-manage-media-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Users">
<app-manage-users></app-manage-users>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Libraries">
<app-manage-library></app-manage-library>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.System">
<app-manage-system></app-manage-system>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Statistics">
<app-server-stats></app-server-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.Tasks">
<app-manage-tasks-settings></app-manage-tasks-settings>
</ng-container>
<ng-container *ngIf="tab.fragment === TabID.KavitaPlus">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
<app-license></app-license>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3 mb-3"></div>
</div>
</ng-container>

View file

@ -1,10 +0,0 @@
.container {
padding-top: 10px;
}
.tab:last-child > a {
&.active, &::before {
background-color: #FFBA15;
}
}

View file

@ -1,88 +0,0 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
import {ActivatedRoute, RouterLink} from '@angular/router';
import {Title} from '@angular/platform-browser';
import {NavService} from '../../_services/nav.service';
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {LicenseComponent} from '../license/license.component';
import {ManageTasksSettingsComponent} from '../manage-tasks-settings/manage-tasks-settings.component';
import {ServerStatsComponent} from '../../statistics/_components/server-stats/server-stats.component';
import {ManageSystemComponent} from '../manage-system/manage-system.component';
import {ManageLogsComponent} from '../manage-logs/manage-logs.component';
import {ManageLibraryComponent} from '../manage-library/manage-library.component';
import {ManageUsersComponent} from '../manage-users/manage-users.component';
import {ManageMediaSettingsComponent} from '../manage-media-settings/manage-media-settings.component';
import {ManageEmailSettingsComponent} from '../manage-email-settings/manage-email-settings.component';
import {ManageSettingsComponent} from '../manage-settings/manage-settings.component';
import {NgFor, NgIf} from '@angular/common';
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {WikiLink} from "../../_models/wiki";
enum TabID {
General = '',
Email = 'email',
Media = 'media',
Users = 'users',
Libraries = 'libraries',
System = 'system',
Tasks = 'tasks',
Logs = 'logs',
Statistics = 'statistics',
KavitaPlus = 'kavitaplus'
}
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink,
NgbNavContent, NgIf, ManageSettingsComponent, ManageEmailSettingsComponent, ManageMediaSettingsComponent,
ManageUsersComponent, ManageLibraryComponent, ManageSystemComponent, ServerStatsComponent,
ManageTasksSettingsComponent, LicenseComponent, NgbNavOutlet, SentenceCasePipe, TranslocoDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly route = inject(ActivatedRoute);
protected readonly navService = inject(NavService);
private readonly titleService = inject(Title);
protected readonly TabID = TabID;
protected readonly WikiLink = WikiLink;
tabs: Array<{title: string, fragment: string}> = [
{title: 'general-tab', fragment: TabID.General},
{title: 'users-tab', fragment: TabID.Users},
{title: 'libraries-tab', fragment: TabID.Libraries},
{title: 'media-tab', fragment: TabID.Media},
{title: 'email-tab', fragment: TabID.Email},
{title: 'tasks-tab', fragment: TabID.Tasks},
{title: 'statistics-tab', fragment: TabID.Statistics},
{title: 'system-tab', fragment: TabID.System},
{title: 'kavita+-tab', fragment: TabID.KavitaPlus},
];
active = this.tabs[0];
constructor() {
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {
this.active = tab[0];
} else {
this.active = this.tabs[0]; // Default to first tab
}
this.cdRef.markForCheck();
});
}
ngOnInit() {
this.titleService.setTitle('Kavita - ' + translate('admin-dashboard.title'));
}
}

View file

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'edit-user'">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h4>
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
</button>
@ -10,7 +10,7 @@
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="col-md-6 col-sm-12 pe-4">
<div class="mb-3">
<label for="username" class="form-label">{{t('username')}}</label>
<input id="username" class="form-control" formControlName="username" type="text"
@ -45,7 +45,7 @@
</div>
<div class="row g-0">
<div class="col-md-6">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>
@ -54,7 +54,7 @@
</div>
</div>
<div class="row g-0">
<div class="row g-0 mt-3">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>

View file

@ -1,15 +1,15 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
import { Library } from 'src/app/_models/library/library';
import { Member } from 'src/app/_models/auth/member';
import { AccountService } from 'src/app/_services/account.service';
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
import { NgIf } from '@angular/common';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {AccountService} from 'src/app/_services/account.service';
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
import {RoleSelectorComponent} from '../role-selector/role-selector.component';
import {NgIf} from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
@ -19,10 +19,15 @@ const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
templateUrl: './edit-user.component.html',
styleUrls: ['./edit-user.component.scss'],
standalone: true,
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective]
imports: [ReactiveFormsModule, NgIf, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditUserComponent implements OnInit {
private readonly accountService = inject(AccountService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly modal = inject(NgbActiveModal);
@Input({required: true}) member!: Member;
selectedRoles: Array<string> = [];
@ -39,7 +44,7 @@ export class EditUserComponent implements OnInit {
public get password() { return this.userForm.get('password'); }
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
ngOnInit(): void {
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email]));
@ -47,18 +52,22 @@ export class EditUserComponent implements OnInit {
this.userForm.get('email')?.disable();
this.selectedRestriction = this.member.ageRestriction;
this.cdRef.markForCheck();
}
updateRoleSelection(roles: Array<string>) {
this.selectedRoles = roles;
this.cdRef.markForCheck();
}
updateRestrictionSelection(restriction: AgeRestriction) {
this.selectedRestriction = restriction;
this.cdRef.markForCheck();
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
this.cdRef.markForCheck();
}
close() {
@ -71,6 +80,7 @@ export class EditUserComponent implements OnInit {
model.roles = this.selectedRoles;
model.libraries = this.selectedLibraries;
model.ageRestriction = this.selectedRestriction;
this.accountService.update(model).subscribe(() => {
this.modal.close(true);
});

View file

@ -24,7 +24,7 @@
</div>
<div class="row g-0">
<div class="col-md-6">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>

View file

@ -1,22 +1,42 @@
<ng-container *transloco="let t; read: 'library-selector'">
<h4>{{t('title')}}</h4>
<div class="list-group" *ngIf="!isLoading">
<div class="form-check" *ngIf="allLibraries.length > 0">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
<div class="d-flex justify-content-between">
<div class="col-auto">
<h4>{{t('title')}}</h4>
</div>
<div class="col-auto">
@if(!isLoading && allLibraries.length > 0) {
<span class="form-check float-end">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
{{t('no-data')}}
</li>
</ul>
</div>
@if (isLoading) {
<app-loading [loading]="isLoading"></app-loading>
} @else {
<div class="list-group">
<ul class="ps-0">
@for (library of allLibraries; track library.name; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</div>
}
</ng-container>

View file

@ -1,21 +1,34 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { Library } from 'src/app/_models/library/library';
import { Member } from 'src/app/_models/auth/member';
import { LibraryService } from 'src/app/_services/library.service';
import { SelectionModel } from 'src/app/typeahead/_components/typeahead.component';
import { NgIf, NgFor } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Library} from 'src/app/_models/library/library';
import {Member} from 'src/app/_models/auth/member';
import {LibraryService} from 'src/app/_services/library.service';
import {TranslocoDirective} from "@ngneat/transloco";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {SelectionModel} from "../../typeahead/_models/selection-model";
@Component({
selector: 'app-library-selector',
templateUrl: './library-selector.component.html',
styleUrls: ['./library-selector.component.scss'],
standalone: true,
imports: [NgIf, ReactiveFormsModule, FormsModule, NgFor, TranslocoDirective]
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, LoadingComponent],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibrarySelectorComponent implements OnInit {
private readonly libraryService = inject(LibraryService);
private readonly cdRef = inject(ChangeDetectorRef);
@Input() member: Member | undefined;
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
@ -29,7 +42,6 @@ export class LibrarySelectorComponent implements OnInit {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(private libraryService: LibraryService, private fb: FormBuilder) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {
@ -51,12 +63,14 @@ export class LibrarySelectorComponent implements OnInit {
this.selectAll = this.selections.selected().length === this.allLibraries.length;
this.selected.emit(this.selections.selected());
}
this.cdRef.markForCheck();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
this.selected.emit(this.selections.selected());
this.cdRef.markForCheck();
}
handleSelection(item: Library) {
@ -68,6 +82,7 @@ export class LibrarySelectorComponent implements OnInit {
this.selectAll = true;
}
this.cdRef.markForCheck();
this.selected.emit(this.selections.selected());
}

View file

@ -1,12 +1,15 @@
<ng-container *transloco="let t; read: 'license'">
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-10">
<div class="row mb-2">
<div class="col-8">
<h4 id="license-key-header">{{t('title')}}</h4>
</div>
<div class="col-2 text-end">
<div class="col-4 text-end">
@if (hasLicense) {
@if (hasValidLicense) {
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
@ -39,7 +42,7 @@
</div>
@if (isViewMode) {
<div class="container-fluid row">
<div class="row">
<span class="col-12">
@if (hasLicense) {
<span class="me-1">*********</span>

View file

@ -16,6 +16,7 @@ import {environment} from "../../../environments/environment";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {catchError} from "rxjs";
import {WikiLink} from "../../_models/wiki";
import {RouterLink} from "@angular/router";
@Component({
selector: 'app-license',
@ -23,7 +24,7 @@ import {WikiLink} from "../../_models/wiki";
styleUrls: ['./license.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule, TranslocoDirective]
imports: [NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule, TranslocoDirective, RouterLink]
})
export class LicenseComponent implements OnInit {

View file

@ -1,54 +0,0 @@
<ng-container *transloco="let t; read: 'manage-alerts'">
<p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" [href]="WikiLink.MediaIssues">{{t('description-part-2')}}</a></p>
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<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-alerts')}}</button>
</div>
</div>
</div>
</form>
<table class="table table-striped table-hover table-sm table-hover">
<thead #header>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
{{t('extension-header')}}
</th>
<th scope="col" sortable="filePath" (sort)="onSort($event)">
{{t('file-header')}}
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
{{t('comment-header')}}
</th>
<th scope="col" sortable="details" (sort)="onSort($event)">
{{t('details-header')}}
</th>
</tr>
</thead>
<tbody #container>
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
<ng-container *ngIf="data | filter: filterList as filteredData">
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
<tr *ngFor="let item of filteredData; index as i">
<td>
{{item.extension}}
</td>
<td>
{{item.filePath}}
</td>
<td>
{{item.comment}}
</td>
<td>
{{item.details}}
</td>
</tr>
</ng-container>
</tbody>
</table>
</ng-container>

View file

@ -1,125 +1,162 @@
<ng-container *transloco="let t; read: 'manage-email-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4 id="email-header">{{t('title')}}</h4>
<form [formGroup]="settingsForm">
<p class="alert alert-warning">{{t('setting-description')}}</p>
<p>{{t('setting-description')}}</p>
@if (settingsForm.dirty) {
<ngb-alert [type]="'warning'">
{{t('test-warning')}}
</ngb-alert>
@if (settingsForm.dirty) {
<div class="alert alert-warning">{{t('test-warning')}}</div>
}
<div class="row g-0 mt-2">
@if (settingsForm.get('hostName'); as formControl) {
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<div class="input-group">
<input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
<button class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div>
@if(settingsForm.dirty || settingsForm.touched) {
<div id="hostname-validations" class="invalid-feedback">
@if (formControl.errors?.pattern) {
<div>{{t('host-name-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
<div class="mb-3 pe-2 ps-2 ">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-hostname-help">
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
</span>
<div class="input-group">
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<button class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
<button class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div>
</div>
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
{{t('host-name-validation')}}
</div>
</div>
</div>
<div class="mt-3">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-address" class="form-label">{{t('sender-address-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="senderAddressTooltip" role="button" tabindex="0"></i>
<ng-template #senderAddressTooltip>{{t('sender-address-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-sender-address-help"><ng-container [ngTemplateOutlet]="senderAddressTooltip"></ng-container></span>
<div class="row g-0 mt-2">
@if (settingsForm.get('senderAddress'); as formControl) {
<app-setting-item [title]="t('sender-address-label')" [subtitle]="t('sender-address-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="senderAddress" id="settings-sender-address" />
</div>
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-displayname" class="form-label">{{t('sender-displayname-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="senderDisplayNameTooltip" role="button" tabindex="0"></i>
<ng-template #senderDisplayNameTooltip>{{t('sender-displayname-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-sender-displayname-help"><ng-container [ngTemplateOutlet]="senderDisplayNameTooltip"></ng-container></span>
<div class="row g-0 mt-2">
@if (settingsForm.get('senderDisplayName'); as formControl) {
<app-setting-item [title]="t('sender-displayname-label')" [subtitle]="t('sender-displayname-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="senderDisplayName" id="settings-sender-displayname" />
</div>
</div>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-sender-address" class="form-label">{{t('host-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="hostTooltip" role="button" tabindex="0"></i>
<ng-template #hostTooltip>{{t('host-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-host-help"><ng-container [ngTemplateOutlet]="hostTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="host" id="settings-host" />
</div>
<div class="row g-0 mt-2">
@if (settingsForm.get('host'); as formControl) {
<app-setting-item [title]="t('host-label')" [subtitle]="t('host-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="text" class="form-control" formControlName="host" id="settings-host" />
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-port" class="form-label">{{t('port-label')}}</label>
<input type="number" min="1" class="form-control" aria-describedby="email-header" formControlName="port" id="settings-port" />
</div>
<div class="row g-0 mt-2">
@if (settingsForm.get('port'); as formControl) {
<app-setting-item [title]="t('port-label')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="number" inputmode="numeric" min="1" class="form-control" aria-describedby="email-header" formControlName="port" id="settings-port" />
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-4 col-sm-12 pe-2 ps-2 mb-2">
<div class="form-check form-switch" style="margin-top: 36px">
<input type="checkbox" id="settings-enable-ssl" role="switch" formControlName="enableSsl" class="form-check-input"
aria-labelledby="auto-close-label">
<label class="form-check-label" for="settings-enable-ssl">{{t('enable-ssl-label')}}</label>
<div class="row g-0 mt-2">
@if(settingsForm.get('enableSsl'); as formControl) {
<app-setting-switch [title]="t('enable-ssl-label')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableOpds">
</div>
</div>
</div>
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-username" class="form-label">{{t('username-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="usernameTooltip" role="button" tabindex="0"></i>
<ng-template #usernameTooltip>{{t('username-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-username-help"><ng-container [ngTemplateOutlet]="usernameTooltip"></ng-container></span>
<div class="row g-0 mt-2">
@if (settingsForm.get('userName'); as formControl) {
<app-setting-item [title]="t('username-label')" [subtitle]="t('username-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="userName" id="settings-username" />
</div>
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-password" class="form-label">{{t('password-label')}}</label>
<input type="password" class="form-control" aria-describedby="email-header" formControlName="password" id="settings-password" />
</div>
</div>
<div class="row g-0 mt-2">
@if (settingsForm.get('password'); as formControl) {
<app-setting-item [title]="t('password-label')">
<ng-template #view>
{{formControl.value ? '********' : null | defaultValue}}
</ng-template>
<ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="password" id="settings-password" />
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<label for="settings-size-limit" class="form-label">{{t('size-limit-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="sizeLimitTooltip" role="button" tabindex="0"></i>
<ng-template #sizeLimitTooltip>{{t('size-limit-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-size-limit-help"><ng-container [ngTemplateOutlet]="sizeLimitTooltip"></ng-container></span>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="sizeLimit" id="settings-size-limit" />
</div>
<div class="row g-0 mt-2">
@if (settingsForm.get('sizeLimit'); as formControl) {
<app-setting-item [title]="t('size-limit-label')" [subtitle]="t('size-limit-tooltip')">
<ng-template #view>
{{formControl.value | bytes}}
</ng-template>
<ng-template #edit>
<input type="number" inputmode="numeric" min="1" class="form-control" aria-describedby="email-header" formControlName="sizeLimit" id="settings-size-limit" />
</ng-template>
</app-setting-item>
}
</div>
<div class="col-md-6 col-sm-12 pe-2 ps-2 mb-2">
<div class="form-check form-switch" style="margin-top: 36px">
<input type="checkbox" id="settings-customized-templates" role="switch" formControlName="customizedTemplates" class="form-check-input"
aria-labelledby="auto-close-label">
<label class="form-check-label" for="settings-customized-templates">{{t('customized-templates-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="customizedTemplatesTooltip" role="button" tabindex="0"></i>
<ng-template #customizedTemplatesTooltip>{{t('customized-templates-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-customized-templates-help"><ng-container [ngTemplateOutlet]="customizedTemplatesTooltip"></ng-container></span>
<div class="row g-0 mt-2">
@if(settingsForm.get('customizedTemplates'); as formControl) {
<app-setting-switch [title]="t('customized-templates-label')" [subtitle]="t('customized-templates-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="settings-customized-templates" type="checkbox" class="form-check-input" formControlName="customizedTemplates">
</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="test()">{{t('test')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</form>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mt-4">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="test()">{{t('test')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</form>
</ng-container>

View file

@ -0,0 +1,6 @@
.alert-warning {
--bs-alert-color: #fff3cd;
--bs-alert-bg: transparent;
--bs-alert-border-color: #ffecb5;
font-size: 14px;
}

View file

@ -11,7 +11,11 @@ import {
import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
import {ManageMediaIssuesComponent} from "../manage-media-issues/manage-media-issues.component";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
@Component({
selector: 'app-manage-email-settings',
@ -20,7 +24,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageAlertsComponent, TitleCasePipe, NgbAlert]
ManageMediaIssuesComponent, TitleCasePipe, NgbAlert, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, BytesPipe]
})
export class ManageEmailSettingsComponent implements OnInit {

View file

@ -0,0 +1,7 @@
<app-license></app-license>
@if (accountService.hasValidLicense$ | async) {
<div class="mt-4">
<app-kavitaplus-metadata-breakdown-stats></app-kavitaplus-metadata-breakdown-stats>
</div>
}

View file

@ -0,0 +1,23 @@
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {AsyncPipe} from "@angular/common";
import {
KavitaplusMetadataBreakdownStatsComponent
} from "../../statistics/_components/kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component";
import {LicenseComponent} from "../license/license.component";
import {AccountService} from "../../_services/account.service";
@Component({
selector: 'app-manage-kavitaplus',
standalone: true,
imports: [
AsyncPipe,
KavitaplusMetadataBreakdownStatsComponent,
LicenseComponent
],
templateUrl: './manage-kavitaplus.component.html',
styleUrl: './manage-kavitaplus.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageKavitaplusComponent {
protected readonly accountService = inject(AccountService);
}

View file

@ -1,41 +1,55 @@
<ng-container *transloco="let t; read: 'manage-library'">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>{{t('title')}}</h3></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="addLibrary()" [title]="t('add-library')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span></button>
</div>
</div>
<ul class="list-group">
<li *ngFor="let library of libraries; let idx = index; trackBy: libraryTrackBy" class="list-group-item no-hover">
<div>
<h4>
<span id="library-name--{{idx}}"><a [routerLink]="'/library/' + library.id">{{library.name}}</a></span>&nbsp;
<div class="float-end">
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
</div>
</h4>
</div>
<div>{{t('type-title')}} {{library.type | libraryType}}</div>
<div>{{t('shared-folders-title')}} {{library.folders.length + ' folders'}}</div>
<div>
{{t('last-scanned-title')}}
<span *ngIf="library.lastScanned === '0001-01-01T00:00:00'; else activeDate">Never</span>
<ng-template #activeDate>
{{library.lastScanned | timeAgo | defaultDate}}
</ng-template>
</div>
</li>
<li *ngIf="loading" class="list-group-item">
<div class="spinner-border text-primary" role="status">
<span class="invisible">{{t('loading')}}</span>
</div>
</li>
<li class="list-group-item" *ngIf="libraries.length === 0 && !loading">
{{t('no-data')}}
</li>
</ul>
<div class="position-relative">
<button class="btn btn-primary-outline position-absolute custom-position" (click)="addLibrary()" [title]="t('add-library')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add-library')}}</span>
</button>
</div>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('type-title')}}</th>
<th scope="col">{{t('shared-folders-title')}}</th>
<th scope="col">{{t('last-scanned-title')}}</th>
<th scope="col">{{t('actions-header')}}</th>
</tr>
</thead>
<tbody>
@for(library of libraries; track library.name + library.type + library.folders.length + library.lastScanned; let idx = $index) {
<tr>
<td id="username--{{idx}}">
<a [routerLink]="'/library/' + library.id">{{library.name}}</a>
</td>
<td>
{{library.type | libraryType}}
</td>
<td>
{{t('folder-count', {num: library.folders.length})}}
</td>
<td>
{{library.lastScanned | timeAgo | defaultDate}}
</td>
<td>
<div class="float-end">
@if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
} @else {
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
}
</div>
</td>
</tr>
}
@empty {
@if (loading) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="loading"></app-loading></td></tr>
} @else {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
</tbody>
</table>
</ng-container>

View file

@ -0,0 +1,13 @@
.custom-position {
right: 15px;
top: -42px;
}
.member-name {
word-break: keep-all;
margin: 0;
}
.list-group-item:nth-child(even) {
background-color: var(--elevation-layer1);
}

View file

@ -2,7 +2,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
DestroyRef, HostListener,
inject,
OnInit
} from '@angular/core';
@ -21,10 +21,18 @@ import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { TimeAgoPipe } from '../../_pipes/time-ago.pipe';
import { LibraryTypePipe } from '../../_pipes/library-type.pipe';
import { RouterLink } from '@angular/router';
import { NgFor, NgIf } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {AsyncPipe, TitleCasePipe} from "@angular/common";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
import {ActionService} from "../../_services/action.service";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {BehaviorSubject, Observable} from "rxjs";
@Component({
selector: 'app-manage-library',
@ -32,11 +40,10 @@ import {ActionService} from "../../_services/action.service";
styleUrls: ['./manage-library.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgFor, RouterLink, NgbTooltip, NgIf, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe]
imports: [RouterLink, NgbTooltip, LibraryTypePipe, TimeAgoPipe, SentenceCasePipe, TranslocoModule, DefaultDatePipe, AsyncPipe, DefaultValuePipe, LoadingComponent, TagBadgeComponent, TitleCasePipe, UtcToLocalTimePipe, CardActionablesComponent]
})
export class ManageLibraryComponent implements OnInit {
private readonly actionService = inject(ActionService);
private readonly libraryService = inject(LibraryService);
private readonly modalService = inject(NgbModal);
private readonly toastr = inject(ToastrService);
@ -44,16 +51,27 @@ export class ManageLibraryComponent implements OnInit {
private readonly hubService = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly utilityService = inject(UtilityService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
protected readonly Breakpoint = Breakpoint;
actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
libraries: Library[] = [];
loading = false;
/**
* If a deletion is in progress for a library
*/
deletionInProgress: boolean = false;
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
useActionableSource = new BehaviorSubject<boolean>(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
useActionables$: Observable<boolean> = this.useActionableSource.asObservable();
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
onResize(){
this.useActionableSource.next(this.utilityService.getActiveBreakpoint() <= Breakpoint.Tablet);
}
ngOnInit(): void {
this.getLibraries();
@ -136,9 +154,35 @@ export class ManageLibraryComponent implements OnInit {
}
}
scanLibrary(library: Library) {
this.libraryService.scan(library.id).pipe(take(1)).subscribe(() => {
this.toastr.info(translate('toasts.scan-queued', {name: library.name}));
});
async scanLibrary(library: Library) {
await this.actionService.scanLibrary(library);
}
async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) {
case(Action.Scan):
await this.actionService.scanLibrary(library);
break;
case(Action.RefreshMetadata):
await this.actionService.refreshMetadata(library);
break;
case(Action.GenerateColorScape):
await this.actionService.refreshMetadata(library, undefined, false);
break;
case(Action.Edit):
this.editLibrary(library)
break;
case (Action.Delete):
await this.deleteLibrary(library);
break;
default:
break;
}
}
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);
}
}
}

View file

@ -0,0 +1,53 @@
<ng-container *transloco="let t; read: 'manage-media-issues'">
<p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" [href]="WikiLink.MediaIssues">{{t('description-part-2')}}</a></p>
<form [formGroup]="formGroup">
<div class="row g-0 mb-3">
<div class="col-md-12">
<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-alerts')}}</button>
</div>
</div>
</div>
</form>
<div class="table-responsive-md">
<table class="table table-striped table-sm">
<thead #header>
<tr>
<th scope="col" sortable="filePath" (sort)="onSort($event)">
{{t('file-header')}}
</th>
<th scope="col" sortable="comment" (sort)="onSort($event)">
{{t('comment-header')}}
</th>
<th scope="col" sortable="details" (sort)="onSort($event)">
{{t('created-header')}}
</th>
</tr>
</thead>
<tbody>
@for(item of data | filter: filterList; track item.filePath; let index = $index) {
<tr>
<td>
{{item.filePath}}
</td>
<td>
{{item.comment}}
</td>
<td>
{{item.createdUtc | utcToLocalTime | defaultDate}}
</td>
</tr>
} @empty {
@if (isLoading) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
} @else {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
</tbody>
</table>
</div>
</ng-container>

View file

@ -19,19 +19,20 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { FilterPipe } from '../../_pipes/filter.pipe';
import { LoadingComponent } from '../../shared/loading/loading.component';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
import {WikiLink} from "../../_models/wiki";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
@Component({
selector: 'app-manage-alerts',
templateUrl: './manage-alerts.component.html',
styleUrls: ['./manage-alerts.component.scss'],
selector: 'app-manage-media-issues',
templateUrl: './manage-media-issues.component.html',
styleUrls: ['./manage-media-issues.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, LoadingComponent, NgFor, FilterPipe, SortableHeader, TranslocoDirective]
imports: [ReactiveFormsModule, LoadingComponent, FilterPipe, SortableHeader, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe]
})
export class ManageAlertsComponent implements OnInit {
export class ManageMediaIssuesComponent implements OnInit {
@Output() alertCount = new EventEmitter<number>();
@ViewChildren(SortableHeader<KavitaMediaError>) headers!: QueryList<SortableHeader<KavitaMediaError>>;

View file

@ -1,46 +1,73 @@
<ng-container *transloco="let t; read: 'manage-media-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
<div class="row g-0">
<p>{{t('encode-as-description-part-1')}} <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-2')}}</a>/<a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-3')}}</a>
<br/><b>{{t('encode-as-warning')}}</b>
<form [formGroup]="settingsForm">
<div class="mb-4">
<p>
{{t('encode-as-description-part-1')}} <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-2')}}</a>/<a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">{{t('encode-as-description-part-3')}}</a>
</p>
<div *ngIf="settingsForm.get('encodeMediaAs')?.dirty" class="alert alert-danger" role="alert">{{t('media-warning')}}</div>
<div class="col-md-6 col-sm-12 mb-3 pe-1">
<label for="settings-media-encodeMediaAs" class="form-label me-1">{{t('encode-as-label')}}</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
<ng-template #encodeMediaAsTooltip>{{t('encode-as-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
<option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="settings-media-coverImageSize" class="form-label me-1">{{t('cover-image-size-label')}}</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="coverImageSizeTooltip" role="button" tabindex="0"></i>
<ng-template #coverImageSizeTooltip>{{t('cover-image-size-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-media-coverImageSize-help"><ng-container [ngTemplateOutlet]="coverImageSizeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-media-coverImageSize-help" formControlName="coverImageSize" id="settings-media-coverImageSize">
<option *ngFor="let size of coverImageSizes" [value]="size.value">{{size.title}}</option>
</select>
<div class="alert alert-warning">
{{t('encode-as-warning')}}
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="settings-bookmarksdir" class="form-label">{{t('bookmark-dir-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #bookmarksDirectoryTooltip>{{t('bookmark-dir-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
<div class="input-group">
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
{{t('change')}}
</button>
</div>
<ng-container>
<div class="row g-0 mt-2">
@if(settingsForm.get('encodeMediaAs'); as formControl) {
<app-setting-item [title]="t('encode-as-label')" [subtitle]="t('encode-as-tooltip')">
<ng-template #view>
{{formControl!.value | encodeFormat}}
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="encodeMediaAs">
@for (opt of allEncodeFormats; track opt) {
<option [value]="opt">{{opt | encodeFormat}}</option>
}
</select>
</ng-template>
</app-setting-item>
@if (formControl.dirty) {
<div class="alert alert-danger mt-2" role="alert">{{t('media-warning')}}</div>
}
}
</div>
</div>
<div class="row g-0 mt-2">
@if(settingsForm.get('coverImageSize'); as formControl) {
<app-setting-item [title]="t('cover-image-size-label')" [subtitle]="t('cover-image-size-tooltip')">
<ng-template #view>
{{formControl!.value | coverImageSize}}
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="coverImageSize">
@for (opt of allCoverImageSizes; track opt) {
<option [value]="opt">{{opt | coverImageSize}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-2">
@if(settingsForm.get('bookmarksDirectory'); as formControl) {
<app-setting-item [title]="t('bookmark-dir-label')" [subtitle]="t('bookmark-dir-tooltip')">
<ng-template #view>
{{formControl!.value}}
</ng-template>
<ng-template #edit>
<div class="input-group">
<input readonly id="settings-bookmarksdir" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(formControl.value, 'bookmarksDirectory')">
{{t('change')}}
</button>
</div>
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
@ -49,41 +76,4 @@
</div>
</form>
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
{{t('media-issue-title')}} <span class="ms-1" *ngIf="alertCount > 0">({{alertCount}})</span>
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<app-manage-alerts (alertCount)="alertCount = $event"></app-manage-alerts>
</ng-template>
</div>
</div>
</div>
</div>
<div class="mt-3" ngbAccordion [destroyOnHide]="false">
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton>
{{t('scrobble-issue-title')}} <span class="ms-1" *ngIf="scrobbleCount > 0">({{scrobbleCount}})</span>
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<app-manage-scrobble-errors (scrobbleCount)="scrobbleCount = $event"></app-manage-scrobble-errors>
</ng-template>
</div>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,6 @@
.alert-warning {
--bs-alert-color: #fff3cd;
--bs-alert-bg: transparent;
--bs-alert-border-color: #ffecb5;
font-size: 14px;
}

View file

@ -17,12 +17,17 @@ import {
NgbModal,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {EncodeFormats} from '../_models/encode-format';
import {ManageScrobbleErrorsComponent} from '../manage-scrobble-errors/manage-scrobble-errors.component';
import {ManageAlertsComponent} from '../manage-alerts/manage-alerts.component';
import {allEncodeFormats} from '../_models/encode-format';
import {ManageMediaIssuesComponent} from '../manage-media-issues/manage-media-issues.component';
import {NgFor, NgIf, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import { CoverImageSizes } from '../_models/cover-image-size';
import {allCoverImageSizes} from '../_models/cover-image-size';
import {pageLayoutModes} from "../../_models/preferences/preferences";
import {PageLayoutModePipe} from "../../_pipes/page-layout-mode.pipe";
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";
@Component({
selector: 'app-manage-media-settings',
@ -30,27 +35,25 @@ import { CoverImageSizes } from '../_models/cover-image-size';
styleUrls: ['./manage-media-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, NgFor, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, ManageAlertsComponent, ManageScrobbleErrorsComponent, TranslocoDirective]
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, NgFor, NgbAccordionDirective, NgbAccordionItem,
NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody,
ManageMediaIssuesComponent, TranslocoDirective, PageLayoutModePipe, SettingItemComponent, EncodeFormatPipe, CoverImageSizePipe]
})
export class ManageMediaSettingsComponent implements OnInit {
private readonly translocoService = inject(TranslocoService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService);
private readonly settingsService = inject(SettingsService);
private readonly toastr = inject(ToastrService);
private readonly modalService = inject(NgbModal);
protected readonly allEncodeFormats = allEncodeFormats;
protected readonly allCoverImageSizes = allCoverImageSizes;
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
alertCount: number = 0;
scrobbleCount: number = 0;
coverImageSizes = CoverImageSizes.map(o => {
const newObj = {...o};
newObj.title = translate(o.title);
return newObj;
})
private readonly translocoService = inject(TranslocoService);
private readonly cdRef = inject(ChangeDetectorRef);
get EncodeFormats() { return EncodeFormats; }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { }
ngOnInit(): void {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
@ -85,7 +88,9 @@ export class ManageMediaSettingsComponent implements OnInit {
});
}
resetToDefaults() {
async resetToDefaults() {
if (!await this.confirmService.confirm(translate('toasts.confirm-reset-server-settings'))) return;
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
@ -107,4 +112,6 @@ export class ManageMediaSettingsComponent implements OnInit {
}
});
}
protected readonly pageLayoutModes = pageLayoutModes;
}

View file

@ -1,4 +1,5 @@
<ng-container *transloco="let t; read: 'manage-scrobble-errors'">
<h4>{{t('title')}}</h4>
<p>{{t('description')}}</p>
<form [formGroup]="formGroup">
@ -12,7 +13,7 @@
</div>
</div>
</form>
<table class="table table-striped table-hover table-sm table-hover">
<table class="table table-striped table-sm">
<thead #header>
<tr>
<th scope="col" sortable="seriesId" (sort)="onSort($event)">
@ -30,27 +31,34 @@
</tr>
</thead>
<tbody #container>
<tr *ngIf="isLoading"><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
<ng-container *ngIf="data | filter: filterList as filteredData">
<tr *ngIf="filteredData.length === 0 && !isLoading"><td colspan="4" style="text-align: center;">No issues</td></tr>
<tr *ngFor="let item of filteredData; index as i">
<td>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</td>
<td>
{{item.createdUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.comment}}
</td>
<td>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
</button>
</td>
</tr>
</ng-container>
@if (isLoading) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="isLoading"></app-loading></td></tr>
} @else {
@if(data | filter: filterList; as filteredData) {
@for(item of filteredData; track item.seriesId; let i = $index) {
<tr>
<td>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</td>
<td>
{{item.createdUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.comment}}
</td>
<td>
<button class="btn btn-icon primary-icon" (click)="editSeries(item.seriesId)">
<i class="fa fa-pen me-1" aria-hidden="true"></i>
<span class="visually-hidden">{{t('edit-item-alt', {seriesName: item.details})}}</span>
</button>
</td>
</tr>
}
@empty {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
}
</tbody>
</table>

View file

@ -116,4 +116,6 @@ export class ManageScrobbleErrorsComponent implements OnInit {
modalRef.componentInstance.series = series;
});
}
protected readonly filter = filter;
}

View file

@ -0,0 +1,13 @@
<app-user-scrobble-history></app-user-scrobble-history>
<div class="setting-section-break"></div>
<div class="mt-4">
<app-user-holds></app-user-holds>
</div>
@if(accountService.isAdmin$ | async) {
<div class="setting-section-break"></div>
<div class="mt-4">
<app-manage-scrobble-errors (scrobbleCount)="updateScrobbleErrorCount($event)"></app-manage-scrobble-errors>
</div>
}

View file

@ -0,0 +1,36 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core';
import {ManageScrobbleErrorsComponent} from "../manage-scrobble-errors/manage-scrobble-errors.component";
import {AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
import {map, shareReplay} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobblingHoldsComponent} from "../../user-settings/user-holds/scrobbling-holds.component";
import {
UserScrobbleHistoryComponent
} from "../../_single-module/user-scrobble-history/user-scrobble-history.component";
@Component({
selector: 'app-manage-scrobling',
standalone: true,
imports: [
ManageScrobbleErrorsComponent,
AsyncPipe,
ScrobblingHoldsComponent,
UserScrobbleHistoryComponent
],
templateUrl: './manage-scrobbling.component.html',
styleUrl: './manage-scrobbling.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageScrobblingComponent {
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly accountService = inject(AccountService);
scrobbleCount: number = 0;
updateScrobbleErrorCount(count: number) {
this.scrobbleCount = count;
this.cdRef.markForCheck();
}
}

View file

@ -1,220 +1,326 @@
<ng-container *transloco="let t; read: 'manage-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<h4>{{t('networking-settings-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('hostName'); as formControl) {
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="hostname-validations" class="invalid-feedback">
@if (formControl.errors?.pattern) {
<div>{{t('host-name-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-hostname-help">
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
</span>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
{{t('host-name-validation')}}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('baseUrl'); as formControl) {
<app-setting-item [title]="t('base-url-label')" [subtitle]="t('base-url-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<div class="input-group">
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
</div>
@if(settingsForm.dirty || settingsForm.touched) {
<div id="baseurl-validations" class="invalid-feedback">
@if (formControl.errors?.pattern) {
<div>{{t('base-url-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3">
<label for="settings-baseurl" class="form-label">{{t('base-url-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>{{t('base-url-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-cachedir-help">
<ng-container [ngTemplateOutlet]="baseUrlTooltip"></ng-container>
</span>
<div class="input-group">
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
[class.is-invalid]="settingsForm.get('baseUrl')?.invalid && settingsForm.get('baseUrl')?.touched">
<button class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
</div>
<div id="baseurl-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('baseUrl')?.errors?.pattern">
{{t('base-url-validation')}}
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('ipAddresses'); as formControl) {
<app-setting-item [title]="t('ip-address-label')" [subtitle]="t('ip-address-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<div class="input-group">
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="resetIPAddresses()">{{t('reset')}}</button>
</div>
@if(settingsForm.dirty || settingsForm.touched) {
<div id="ipaddresses-validations" class="invalid-feedback">
@if (formControl.errors?.pattern) {
<div>{{t('ip-address-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mb-2">
<div class="col-md-8 col-sm-12 pe-md-2">
<label for="settings-ipaddresses" class="form-label">{{t('ip-address-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="ipAddressesTooltip" role="button" tabindex="0"></i>
<ng-template #ipAddressesTooltip>{{t('ip-address-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-ipaddresses-help">
<ng-container [ngTemplateOutlet]="ipAddressesTooltip"></ng-container>
</span>
<div class="input-group">
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
[class.is-invalid]="settingsForm.get('ipAddresses')?.invalid && settingsForm.get('ipAddresses')?.touched">
<button class="btn btn-outline-secondary" (click)="resetIPAddresses()">Reset</button>
</div>
<div id="ipaddresses-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('ipAddresses')?.errors?.pattern">
{{t('ip-address-validation')}}
</div>
</div>
</div>
<div class="col-md-4 col-sm-12">
<label for="settings-port" class="form-label">{{t('port-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>{{t('port-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-port-help">
<ng-container [ngTemplateOutlet]="portTooltip"></ng-container>
</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('port'); as formControl) {
<app-setting-item [title]="t('port-label')" [subtitle]="t('port-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control"
formControlName="port" type="number" step="1" min="1"
onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mb-2">
<div class="col-md-4 col-sm-12 pe-md-2">
<label for="backup-tasks" class="form-label">{{t('backup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="backupTasksTooltip" role="button" tabindex="0"></i>
<ng-template #backupTasksTooltip>{{t('backup-tooltip')}}.</ng-template>
<span class="visually-hidden" id="backup-tasks-help">
<ng-container [ngTemplateOutlet]="backupTasksTooltip"></ng-container>
</span>
<input id="backup-tasks" aria-describedby="backup-tasks-help" class="form-control" formControlName="totalBackups"
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="settingsForm.get('totalBackups')?.invalid && settingsForm.get('totalBackups')?.touched">
<ng-container *ngIf="settingsForm.get('totalBackups')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
{{t('min-backup-validation')}}
</p>
<p class="invalid-feedback" *ngIf="errors.max">
{{t('max-backup-validation', {num: errors.max.max})}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('field-required')}}
</p>
</ng-container>
</div>
</ng-container>
<div class="col-md-4 col-sm-12 pe-md-2">
<label for="log-tasks" class="form-label">{{t('log-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="logTasksTooltip" role="button" tabindex="0"></i>
<ng-template #logTasksTooltip>{{t('log-tooltip')}}</ng-template>
<span class="visually-hidden" id="log-tasks-help">
<ng-container [ngTemplateOutlet]="logTasksTooltip"></ng-container>
</span>
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs"
type="number" inputmode="numeric" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="settingsForm.get('totalLogs')?.invalid && settingsForm.get('totalLogs')?.touched">
<ng-container *ngIf="settingsForm.get('totalLogs')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
{{t('min-log-validation')}}
</p>
<p class="invalid-feedback" *ngIf="errors.max">
{{t('max-logs-validation', {num: errors.max.max})}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('field-required')}}
</p>
</ng-container>
</div>
<div class="setting-section-break"></div>
<div class="col-md-4 col-sm-12">
<label for="logging-level-port" class="form-label">{{t('logging-level-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>{{t('logging-level-tooltip')}}</ng-template>
<span class="visually-hidden" id="logging-level-port-help">
<ng-container [ngTemplateOutlet]="loggingLevelTooltip"></ng-container>
</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel"
[class.is-invalid]="settingsForm.get('loggingLevel')?.invalid && settingsForm.get('loggingLevel')?.touched">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>
<h4>{{t('system-settings-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('totalBackups'); as formControl) {
<app-setting-item [title]="t('backup-label')" [subtitle]="t('backup-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="settings-backup" aria-describedby="total-backups-validations" class="form-control"
formControlName="totalBackups" type="number" inputmode="numeric" step="1" min="1" max="30"
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="total-backups-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('field-required')}}</div>
}
@if (formControl.errors?.max) {
<div>{{t('max-backup-validation', {num: formControl.errors?.max?.max})}}</div>
}
@if (formControl.errors?.min) {
<div>{{t('min-backup-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mb-2 mt-3">
<div class="col-md-4 col-sm-12 pe-md-2">
<label for="cache-size" class="form-label">{{t('cache-size-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="cacheSizeTooltip" role="button" tabindex="0"></i>
<ng-template #cacheSizeTooltip>{{t('cache-size-tooltip')}}</ng-template>
<span class="visually-hidden" id="cache-size-help">
<ng-container [ngTemplateOutlet]="cacheSizeTooltip"></ng-container>
</span>
<input id="cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="settingsForm.get('cacheSize')?.invalid && settingsForm.get('cacheSize')?.touched">
<ng-container *ngIf="settingsForm.get('cacheSize')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
{{t('min-cache-validation')}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('field-required')}}
</p>
</ng-container>
</div>
<div class="col-md-4 col-sm-12 pe-md-2">
<label for="on-deck-progress-days" class="form-label">{{t('on-deck-last-progress-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="onDeckProgressDaysTooltip" role="button" tabindex="0"></i>
<ng-template #onDeckProgressDaysTooltip>{{t('on-deck-last-progress-tooltip')}}</ng-template>
<span class="visually-hidden" id="on-deck-progress-days-help">
<ng-container [ngTemplateOutlet]="onDeckProgressDaysTooltip"></ng-container>
</span>
<input id="on-deck-progress-days" aria-describedby="on-deck-progress-days-help" class="form-control" formControlName="onDeckProgressDays"
type="number" inputmode="numeric" step="1" min="1"
[class.is-invalid]="settingsForm.get('onDeckProgressDays')?.invalid && settingsForm.get('onDeckProgressDays')?.touched">
<ng-container *ngIf="settingsForm.get('onDeckProgressDays')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
{{t('min-days-validation')}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('field-required')}}
</p>
</ng-container>
</div>
<div class="col-md-4 col-sm-12">
<label for="on-deck-update-days" class="form-label">{{t('on-deck-last-chapter-add-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="onDeckUpdateDaysTooltip" role="button" tabindex="0"></i>
<ng-template #onDeckUpdateDaysTooltip>{{t('on-deck-last-chapter-add-tooltip')}}</ng-template>
<span class="visually-hidden" id="on-deck-update-days-help">
<ng-container [ngTemplateOutlet]="onDeckUpdateDaysTooltip"></ng-container>
</span>
<input id="on-deck-update-days" aria-describedby="on-deck-update-days-help" class="form-control" formControlName="onDeckUpdateDays"
type="number" inputmode="numeric" step="1" min="1"
[class.is-invalid]="settingsForm.get('onDeckUpdateDays')?.invalid && settingsForm.get('onDeckUpdateDays')?.touched">
<ng-container *ngIf="settingsForm.get('onDeckUpdateDays')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.min">
{{t('min-days-validation')}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('field-required')}}
</p>
</ng-container>
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('totalLogs'); as formControl) {
<app-setting-item [title]="t('log-label')" [subtitle]="t('log-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="settings-logs" aria-describedby="total-logs-validations" class="form-control"
formControlName="totalLogs" type="number" inputmode="numeric" step="1" min="1" max="30"
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="total-logs-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('field-required')}}</div>
}
@if (formControl.errors?.max) {
<div>{{t('max-logs-validation', {num: formControl.errors?.max?.max})}}</div>
}
@if (formControl.errors?.min) {
<div>{{t('min-log-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3 mt-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">{{t('allow-stats-label')}}</label>
<p class="accent" id="collection-info">{{t('allow-stats-tooltip-part-1')}}<a [href]="WikiLink.DataCollection" rel="noopener noreferrer" target="_blank" referrerpolicy="no-refer">wiki</a> {{t('allow-stats-tooltip-part-2')}}</p>
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">{{t('send-data')}}</label>
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('loggingLevel'); as formControl) {
<app-setting-item [title]="t('logging-level-label')" [subtitle]="t('logging-level-tooltip')">
<ng-template #view>
{{formControl.value | titlecase}}
</ng-template>
<ng-template #edit>
<select id="logging-level" aria-describedby="logging-level-help" class="form-select" formControlName="loggingLevel"
[class.is-invalid]="formControl.invalid && formControl.touched">
@for(level of logLevels; track level) {
<option [value]="level">{{level | titlecase}}</option>
}
</select>
@if(settingsForm.dirty || settingsForm.touched) {
<div id="logging-level-validations" class="invalid-feedback">
@if (formControl.errors?.pattern) {
<div>{{t('host-name-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3">
<label for="opds" aria-describedby="opds-info" class="form-label">{{t('opds-label')}}</label>
<p class="accent" id="opds-info">{{t('opds-tooltip')}}</p>
<div class="form-check form-switch">
<input id="opds" type="checkbox" aria-label="OPDS Support" class="form-check-input" formControlName="enableOpds">
<label for="opds" class="form-check-label">{{t('enable-opds')}}</label>
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('cacheSize'); as formControl) {
<app-setting-item [title]="t('cache-size-label')" [subtitle]="t('cache-size-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="setting-cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="cache-size-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('field-required')}}</div>
}
@if (formControl.errors?.min) {
<div>{{t('min-cache-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3">
<label for="folder-watching" class="form-label" aria-describedby="folder-watching-info">{{t('folder-watching-label')}}</label>
<p class="accent" id="folder-watching-info">{{t('folder-watching-tooltip')}}</p>
<div class="form-check form-switch">
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
<label for="folder-watching" class="form-check-label">{{t('enable-folder-watching')}}</label>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableOpds'); as formControl) {
<app-setting-switch [title]="t('opds-label')" [subtitle]="t('opds-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="opds" type="checkbox" [attr.aria-label]="t('opds-label')" class="form-check-input" formControlName="enableOpds">
</div>
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('enableFolderWatching'); as formControl) {
<app-setting-switch [title]="t('folder-watching-label')" [subtitle]="t('folder-watching-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="folder-watching" type="checkbox" class="form-check-input" formControlName="enableFolderWatching" role="switch">
</div>
</div>
</ng-template>
</app-setting-switch>
}
</div>
</form>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('allowStatCollection'); as formControl) {
<app-setting-switch [title]="t('allow-stats-label')" [subtitle]="allowStatsTooltip">
<ng-template #switch>
<div class="form-check form-switch">
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" class="form-check-input" formControlName="allowStatCollection" role="switch">
</div>
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('customization-settings-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('onDeckProgressDays'); as formControl) {
<app-setting-item [title]="t('on-deck-last-progress-label')" [subtitle]="t('on-deck-last-progress-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="setting-on-deck-progress-days" aria-describedby="on-deck-progress-days-validations" class="form-control" formControlName="onDeckProgressDays"
type="number" inputmode="numeric" step="1" min="1"
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="on-deck-last-progress-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('field-required')}}</div>
}
@if (formControl.errors?.min) {
<div>{{t('min-days-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('onDeckUpdateDays'); as formControl) {
<app-setting-item [title]="t('on-deck-last-chapter-add-label')" [subtitle]="t('on-deck-last-progress-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="on-deck-last-chapter-add" aria-describedby="on-deck-last-chapter-add-validations" class="form-control" formControlName="onDeckUpdateDays"
type="number" inputmode="numeric" step="1" min="1"
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
[class.is-invalid]="formControl.invalid && formControl.touched">
@if(settingsForm.dirty || settingsForm.touched) {
<div id="on-deck-last-chapter-add-validations" class="invalid-feedback">
@if (formControl.errors?.required) {
<div>{{t('field-required')}}</div>
}
@if (formControl.errors?.min) {
<div>{{t('min-days-validation')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mt-4">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</form>
</ng-container>

View file

@ -1,3 +1,10 @@
.invalid-feedback {
display: inherit;
}
.alert-warning {
--bs-alert-color: #fff3cd;
--bs-alert-bg: transparent;
--bs-alert-border-color: #ffecb5;
font-size: 14px;
}

View file

@ -6,9 +6,14 @@ import {ServerService} from 'src/app/_services/server.service';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
import {NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule, TranslocoService} from "@ngneat/transloco";
import {WikiLink} from "../../_models/wiki";
import {PageLayoutModePipe} from "../../_pipes/page-layout-mode.pipe";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ConfirmService} from "../../shared/confirm.service";
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
@ -18,7 +23,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
styleUrls: ['./manage-settings.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, TitleCasePipe, TranslocoModule, NgTemplateOutlet]
imports: [ReactiveFormsModule, NgbTooltip, TitleCasePipe, TranslocoModule, NgTemplateOutlet, PageLayoutModePipe, SettingItemComponent, SettingSwitchComponent, SafeHtmlPipe]
})
export class ManageSettingsComponent implements OnInit {
@ -27,6 +32,7 @@ export class ManageSettingsComponent implements OnInit {
private readonly settingsService = inject(SettingsService);
private readonly toastr = inject(ToastrService);
private readonly serverService = inject(ServerService);
private readonly confirmService = inject(ConfirmService);
protected readonly WikiLink = WikiLink;
serverSettings!: ServerSettings;
@ -34,6 +40,11 @@ export class ManageSettingsComponent implements OnInit {
taskFrequencies: Array<string> = [];
logLevels: Array<string> = [];
allowStatsTooltip = translate('manage-settings.allow-stats-tooltip-part-1') + ' <a href="' +
WikiLink.DataCollection +
'" rel="noopener noreferrer" target="_blank">wiki</a> ' +
translate('manage-settings.allow-stats-tooltip-part-2');
ngOnInit(): void {
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
this.taskFrequencies = frequencies;
@ -116,8 +127,10 @@ export class ManageSettingsComponent implements OnInit {
});
}
resetToDefaults() {
this.settingsService.resetServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
async resetToDefaults() {
if (!await this.confirmService.confirm(translate('toasts.confirm-reset-server-settings'))) return;
this.settingsService.resetServerSettings().subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success(this.translocoService.translate('toasts.server-settings-updated'));

View file

@ -0,0 +1,6 @@
.alert-warning {
--bs-alert-color: #fff3cd;
--bs-alert-bg: transparent;
--bs-alert-border-color: #ffecb5;
font-size: 14px;
}

View file

@ -1,97 +1,127 @@
<ng-container *transloco="let t; read: 'manage-tasks-settings'">
<div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>{{t('title')}}</h4>
<div class="mb-3">
<label for="settings-tasks-scan" class="form-label">{{t('library-scan-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<ng-template #taskScanTooltip>{{t('library-scan-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-tasks-scan-help"><ng-container [ngTemplateOutlet]="taskScanTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{t(freq)}}</option>
</select>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskScan'); as formControl) {
<app-setting-item [title]="t('library-scan-label')" [subtitle]="t('library-scan-tooltip')">
<ng-template #view>
{{t(formControl.value)}}
</ng-template>
<ng-template #edit>
@if (settingsForm.get('taskScan')!.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-scan" formControlName="taskScanCustom"
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
aria-describedby="task-scan-validations">
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</select>
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-scan-validations" class="invalid-feedback">
<div *ngIf="settingsForm.get('taskScanCustom')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="settingsForm.get('taskScanCustom')?.errors?.invalidCron">
{{t('cron-notation')}}
</div>
</div>
}
</div>
}
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-scan" formControlName="taskScanCustom"
[class.is-invalid]="settingsForm.get('taskScanCustom')?.invalid && settingsForm.get('taskScanCustom')?.touched"
aria-describedby="task-scan-validations">
</div>
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-scan-validations" class="invalid-feedback">
@if(settingsForm.get('taskScanCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskScanCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="mb-3">
<label for="settings-tasks-backup" class="form-label">{{t('library-database-backup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>{{t('library-database-backup-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-tasks-backup-help"><ng-container [ngTemplateOutlet]="taskBackupTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{t(freq)}}</option>
</select>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskBackup'); as formControl) {
<app-setting-item [title]="t('library-database-backup-label')" [subtitle]="t('library-database-backup-tooltip')">
<ng-template #view>
{{t(formControl.value)}}
</ng-template>
<ng-template #edit>
@if (settingsForm.get('taskBackup')!.value === customOption) {
<div class="mt-3">
<label for="custom-task-backup" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-backup" formControlName="taskBackupCustom"
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
aria-describedby="task-backup-validations">
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
@for(freq of taskFrequencies; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</select>
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-backup-validations" class="invalid-feedback">
<div *ngIf="settingsForm.get('taskBackupCustom')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="settingsForm.get('taskBackupCustom')?.errors?.invalidCron">
{{t('cron-notation')}}
</div>
</div>
}
</div>
}
</div>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-backup" formControlName="taskBackupCustom"
[class.is-invalid]="settingsForm.get('taskBackupCustom')?.invalid && settingsForm.get('taskBackupCustom')?.touched"
aria-describedby="task-scan-validations">
<div class="mb-3">
<label for="settings-tasks-cleanup" class="form-label">{{t('cleanup-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="taskCleanupTooltip" role="button" tabindex="0"></i>
<ng-template #taskCleanupTooltip>{{t('cleanup-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-tasks-cleanup-help"><ng-container [ngTemplateOutlet]="taskCleanupTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-tasks-cleanup-help" formControlName="taskCleanup" id="settings-tasks-cleanup">
<option *ngFor="let freq of taskFrequenciesForCleanup" [value]="freq">{{t(freq)}}</option>
</select>
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-backup-validations" class="invalid-feedback">
@if(settingsForm.get('taskBackupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskBackupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
@if (settingsForm.get('taskCleanup')!.value === customOption) {
<div class="mt-3">
<label for="custom-task-cleanup" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-cleanup" formControlName="taskCleanupCustom"
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
aria-describedby="task-cleanup-validations">
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-cleanup-validations" class="invalid-feedback">
<div *ngIf="settingsForm.get('taskCleanupCustom')?.errors?.required">
{{t('required')}}
</div>
<div *ngIf="settingsForm.get('taskCleanupCustom')?.errors?.invalidCron">
{{t('cron-notation')}}
</div>
</div>
}
</div>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('taskCleanup'); as formControl) {
<app-setting-item [title]="t('cleanup-label')" [subtitle]="t('cleanup-tooltip')">
<ng-template #view>
{{t(formControl.value)}}
</ng-template>
<ng-template #edit>
<select class="form-select" aria-describedby="settings-tasks-cleanup-help" formControlName="taskCleanup" id="settings-tasks-cleanup">
@for(freq of taskFrequenciesForCleanup; track freq) {
<option [value]="freq">{{t(freq)}}</option>
}
</select>
@if (formControl.value === customOption) {
<div class="mt-3">
<label for="custom-task-scan" class="form-label">{{t('custom-label')}}</label>
<input class="form-control" type="text"
id="custom-task-cleanup" formControlName="taskCleanupCustom"
[class.is-invalid]="settingsForm.get('taskCleanupCustom')?.invalid && settingsForm.get('taskCleanupCustom')?.touched"
aria-describedby="task-scan-validations">
@if (settingsForm.dirty || settingsForm.touched) {
<div id="task-cleanup-validations" class="invalid-feedback">
@if(settingsForm.get('taskCleanupCustom')?.errors?.required) {
<div>{{t('required')}}</div>
}
@if(settingsForm.get('taskCleanupCustom')?.errors?.invalidCron) {
<div>{{t('cron-notation')}}</div>
}
</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>
@ -99,29 +129,19 @@
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
<div class="setting-section-break"></div>
<h4>{{t('adhoc-tasks-title')}}</h4>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">{{t('job-title-header')}}</th>
<th scope="col">{{t('description-header')}}</th>
<th scope="col">{{t('action-header')}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let task of adhocTasks; let idx = index;">
<td id="adhoctask--{{idx}}">
{{t(task.name)}}
</td>
<td>
{{t(task.description)}}
</td>
<td>
<button class="btn btn-primary" (click)="runAdhoc(task)" attr.aria-labelledby="adhoctask--{{idx}}">Run</button>
</td>
</tr>
</tbody>
</table>
@for(task of adhocTasks; track task.name; let idx = $index) {
<div class="mt-4 mb-4">
<app-setting-button [subtitle]="t(task.description)">
<button class="btn btn-secondary btn-sm mb-2" (click)="runAdhoc(task)">{{t(task.name)}}</button>
</app-setting-button>
</div>
}
<div class="setting-section-break"></div>
<h4>{{t('recurring-tasks-title')}}</h4>
<table class="table table-striped">
@ -133,15 +153,17 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let task of recurringTasks$ | async; index as i">
<td>
{{task.title | titlecase}}
</td>
<td>
{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}
</td>
<td>{{task.cron}}</td>
</tr>
@for(task of recurringTasks$ | async; track task.lastExecutionUtc + task.cron; let idx = $index) {
<tr>
<td>
{{task.title | titlecase}}
</td>
<td>
{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}
</td>
<td>{{task.cron}}</td>
</tr>
}
</tbody>
</table>
</form>

View file

@ -1,3 +1,3 @@
.table {
background-color: lightgrey;
td {
font-size: .9rem;
}

View file

@ -4,7 +4,7 @@ import {ToastrService} from 'ngx-toastr';
import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings';
import {shareReplay, take} from 'rxjs/operators';
import {debounceTime, defer, distinctUntilChanged, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
import {debounceTime, defer, forkJoin, Observable, of, switchMap, tap} from 'rxjs';
import {ServerService} from 'src/app/_services/server.service';
import {Job} from 'src/app/_models/job/job';
import {UpdateNotificationModalComponent} from 'src/app/shared/update-notification/update-notification-modal.component';
@ -17,6 +17,9 @@ import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {ConfirmService} from "../../shared/confirm.service";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
interface AdhocTask {
name: string;
@ -33,12 +36,18 @@ interface AdhocTask {
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe,
TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe]
TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, SettingButtonComponent]
})
export class ManageTasksSettingsComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly confirmService = inject(ConfirmService);
private readonly settingsService = inject(SettingsService);
private readonly toastr = inject(ToastrService);
private readonly serverService = inject(ServerService);
private readonly modalService = inject(NgbModal);
private readonly downloadService = inject(DownloadService);
serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({});
@ -113,9 +122,6 @@ export class ManageTasksSettingsComponent implements OnInit {
];
customOption = 'custom';
constructor(private settingsService: SettingsService, private toastr: ToastrService,
private serverService: ServerService, private modalService: NgbModal,
private downloadService: DownloadService) { }
ngOnInit(): void {
forkJoin({
@ -262,7 +268,9 @@ export class ManageTasksSettingsComponent implements OnInit {
});
}
resetToDefaults() {
async resetToDefaults() {
if (!await this.confirmService.confirm(translate('toasts.confirm-reset-server-settings'))) return;
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();

View file

@ -1,18 +1,73 @@
<ng-container *transloco="let t; read: 'manage-users'">
<div class="position-relative">
<button class="btn btn-primary-outline position-absolute custom-position" (click)="inviteUser()">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{t('invite')}}</span>
</button>
</div>
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>{{t('title')}}</h3></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{t('invite')}}</span></button></div>
</div>
<ul class="list-group">
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
<div>
<h4>
<span id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
<div class="float-end" *ngIf="canEditMember(member)">
<table class="table table-striped">
<thead>
<tr>
<th scope="col">{{t('name-header')}}</th>
<th scope="col">{{t('last-active-header')}}</th>
<th scope="col">{{t('sharing-header')}}</th>
<th scope="col">{{t('roles-header')}}</th>
<th scope="col">{{t('actions-header')}}</th>
</tr>
</thead>
<tbody>
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
<tr>
<td id="username--{{idx}}">
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
@if (member.isPending) {
<span class="badge bg-secondary text-dark ms-1 pending-badge" [ngbTooltip]="t('pending-tooltip')">{{t('pending-title')}}</span>
}
</td>
<td>
<span [ngbTooltip]="member.lastActiveUtc | utcToLocalTime | defaultDate">
@if ((messageHub.onlineUsers$ | async)?.includes(member.username)) {
{{t('online-now-tooltip')}}
} @else {
{{member.lastActiveUtc | utcToLocalTime | timeAgo | sentenceCase | defaultDate}}
}
</span>
</td>
<td>
@if (!hasAdminRole(member) && member.libraries.length > 0) {
@if (member.libraries.length > 5) {
{{t('too-many-libraries')}}
}
@else {
@for(lib of member.libraries; track lib.name) {
<app-tag-badge class="col-auto">{{lib.name}}</app-tag-badge>
}
}
} @else {
{{null | defaultValue}}
}
</td>
<td>
@if (getRoles(member); as roles) {
<div>
@if (roles.length === 0) {
<span>{{null | defaultValue}}</span>
} @else {
@if (hasAdminRole(member)) {
<app-tag-badge class="col-auto">{{t('admin')}}</app-tag-badge>
} @else {
@for (role of roles; track role) {
<app-tag-badge class="col-auto">{{role}}</app-tag-badge>
}
}
}
</div>
} @else {
{{null | defaultValue}}
}
</td>
<td>
@if (canEditMember(member)) {
<button class="btn btn-danger btn-sm me-2" (click)="deleteUser(member)"
placement="top" [ngbTooltip]="t('delete-user-tooltip')" [attr.aria-label]="t('delete-user-alt', {user: member.username | titlecase})">
<i class="fa fa-trash" aria-hidden="true"></i>
@ -22,51 +77,26 @@
<i class="fa fa-pen" aria-hidden="true"></i>
</button>
<ng-container *ngIf="member.isPending">
@if (member.isPending) {
<button class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)"
placement="top" [ngbTooltip]="t('resend-invite-tooltip')" [attr.aria-label]="t('resend-invite-alt', {user: member.username | titlecase})"><i class="fa-solid fa-share-from-square" aria-hidden="true"></i></button>
<button class="btn btn-secondary btn-sm" (click)="setup(member)"
placement="top" [ngbTooltip]="t('setup-user-tooltip')" [attr.aria-label]="t('setup-user-alt', {user: member.username | titlecase})"><i class="fa-solid fa-sliders" aria-hidden="true"></i></button>
</ng-container>
<button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (click)="updatePassword(member)"
placement="top" [ngbTooltip]="t('change-password-tooltip')" [attr.aria-label]="t('change-password-alt', {user: member.username | titlecase})"><i class="fa fa-key" aria-hidden="true"></i></button>
</div>
</h4>
<div class="user-info">
<div>{{t('last-active-title')}}
<span class="badge bg-secondary text-dark ms-1 pending-badge" *ngIf="member.isPending; else activeTime">{{t('pending-title')}}</span>
<ng-template #activeTime>
<span>{{member.lastActiveUtc | utcToLocalTime | defaultDate}} <i class="presence fa fa-circle ms-1" [title]="t('online-now-tooltip')" aria-hidden="true" *ngIf="(messageHub.onlineUsers$ | async)?.includes(member.username)"></i></span>
</ng-template>
</div>
<div *ngIf="!hasAdminRole(member) && member.libraries.length > 0">
{{t('sharing-title')}}
<app-tag-badge *ngFor="let lib of member.libraries" class="col-auto">{{lib.name}}</app-tag-badge>
</div>
<div class="row g-0">
<div *ngIf="getRoles(member) as roles">
{{t('roles-title')}} <span *ngIf="roles.length === 0; else showRoles">{{null | defaultValue}}</span>
<ng-template #showRoles>
<ng-container *ngIf="hasAdminRole(member); else allRoles">
<app-tag-badge class="col-auto">{{t('admin')}}</app-tag-badge>
</ng-container>
<ng-template #allRoles>
<app-tag-badge *ngFor="let role of roles" class="col-auto">{{role}}</app-tag-badge>
</ng-template>
</ng-template>
</div>
</div>
</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">{{t('loading')}}</span>
</div>
</li>
<li class="list-group-item" *ngIf="members.length === 0 && !loadingMembers">
{{t('no-data')}}
</li>
</ul>
</div>
} @else {
<button class="btn btn-secondary btn-sm" (click)="updatePassword(member)"
placement="top" [ngbTooltip]="t('change-password-tooltip')" [attr.aria-label]="t('change-password-alt', {user: member.username | titlecase})"><i class="fa fa-key" aria-hidden="true"></i></button>
}
}
</td>
</tr>
}
@empty {
@if (loadingMembers) {
<tr><td colspan="4" style="text-align: center;"><app-loading [loading]="loadingMembers"></app-loading></td></tr>
} @else {
<tr><td colspan="4" style="text-align: center;">{{t('no-data')}}</td></tr>
}
}
</tbody>
</table>
</ng-container>

View file

@ -12,5 +12,23 @@
}
.pending-badge {
font-size: 15px;
font-size: 12px;
}
.custom-position {
right: 15px;
top: -42px;
}
.member-name {
word-break: keep-all;
margin: 0;
}
.actions {
min-width: 152px;
}
.list-group-item:nth-child(even) {
background-color: var(--elevation-layer1);
}

View file

@ -10,15 +10,18 @@ import {ConfirmService} from 'src/app/shared/confirm.service';
import {MessageHubService} from 'src/app/_services/message-hub.service';
import {InviteUserComponent} from '../invite-user/invite-user.component';
import {EditUserComponent} from '../edit-user/edit-user.component';
import {ServerService} from 'src/app/_services/server.service';
import {Router} from '@angular/router';
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
import {AsyncPipe, DatePipe, NgClass, NgFor, NgIf, TitleCasePipe} from '@angular/common';
import {AsyncPipe, DatePipe, NgClass, NgIf, TitleCasePipe} from '@angular/common';
import {translate, TranslocoModule, TranslocoService} from "@ngneat/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {makeBindingParser} from "@angular/compiler";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
@Component({
selector: 'app-manage-users',
@ -26,7 +29,7 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
styleUrls: ['./manage-users.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgFor, NgIf, NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, DatePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, ReadMoreComponent, UtcToLocalTimePipe]
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, DatePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, ReadMoreComponent, UtcToLocalTimePipe, LoadingComponent, NgIf, TimeAgoPipe, SentenceCasePipe]
})
export class ManageUsersComponent implements OnInit {
@ -42,7 +45,6 @@ export class ManageUsersComponent implements OnInit {
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
public readonly messageHub = inject(MessageHubService);
private readonly serverService = inject(ServerService);
private readonly router = inject(Router);
constructor() {
@ -85,7 +87,7 @@ export class ManageUsersComponent implements OnInit {
}
openEditUser(member: Member) {
const modalRef = this.modalService.open(EditUserComponent, {size: 'lg'});
const modalRef = this.modalService.open(EditUserComponent, { scrollable: true, size: 'xl', fullscreen: 'md' });
modalRef.componentInstance.member = member;
modalRef.closed.subscribe(() => {
this.loadMembers();
@ -105,7 +107,7 @@ export class ManageUsersComponent implements OnInit {
}
inviteUser() {
const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'});
const modalRef = this.modalService.open(InviteUserComponent, {size: 'xl'});
modalRef.closed.subscribe((successful: boolean) => {
this.loadMembers();
});
@ -151,4 +153,5 @@ export class ManageUsersComponent implements OnInit {
return member.roles.filter(item => item != 'Pleb');
}
protected readonly makeBindingParser = makeBindingParser;
}

View file

@ -1,12 +1,28 @@
<ng-container *transloco="let t; read:'role-selector'">
<h4>{{t('title')}}</h4>
<div class="d-flex justify-content-between">
<div class="col-auto">
<h4>{{t('title')}}</h4>
</div>
<div class="col-auto">
@if(selectedRoles.length > 0) {
<span class="form-check float-end">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
</div>
<ul class="list-group">
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
<div class="form-check">
<input id="role-{{i}}" type="checkbox" class="form-check-input"
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
<label for="role-{{i}}" class="form-check-label">{{role.data}}</label>
</div>
</li>
@for(role of selectedRoles; track role; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="role-{{i}}" type="checkbox" class="form-check-input"
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
<label for="role-{{i}}" class="form-check-label">{{role.data}}</label>
</div>
</li>
}
</ul>
</ng-container>

View file

@ -1,11 +1,20 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import { Member } from 'src/app/_models/auth/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import {AccountService} from 'src/app/_services/account.service';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { NgFor } from '@angular/common';
import {TranslocoDirective,} from "@ngneat/transloco";
import {SelectionModel} from "../../typeahead/_models/selection-model";
@Component({
selector: 'app-role-selector',
@ -17,6 +26,10 @@ import {TranslocoDirective,} from "@ngneat/transloco";
})
export class RoleSelectorComponent implements OnInit {
private readonly accountService = inject(AccountService);
private readonly cdRef = inject(ChangeDetectorRef);
/**
* This must have roles
*/
@ -29,8 +42,12 @@ export class RoleSelectorComponent implements OnInit {
allRoles: string[] = [];
selectedRoles: Array<{selected: boolean, disabled: boolean, data: string}> = [];
selections!: SelectionModel<string>;
selectAll: boolean = false;
constructor(public modal: NgbActiveModal, private accountService: AccountService, private readonly cdRef: ChangeDetectorRef) { }
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
ngOnInit(): void {
this.accountService.getRoles().subscribe(roles => {
@ -40,11 +57,15 @@ export class RoleSelectorComponent implements OnInit {
}
roles = roles.filter(item => !bannedRoles.includes(item));
this.allRoles = roles;
this.selections = new SelectionModel<string>(false, this.allRoles);
this.selectedRoles = roles.map(item => {
return {selected: false, disabled: false, data: item};
});
this.cdRef.markForCheck();
this.preselect();
this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data));
});
}
@ -65,6 +86,7 @@ export class RoleSelectorComponent implements OnInit {
}
});
}
this.syncSelections();
this.cdRef.markForCheck();
}
@ -81,8 +103,27 @@ export class RoleSelectorComponent implements OnInit {
e.disabled = false;
});
}
this.syncSelections();
this.cdRef.markForCheck();
this.selected.emit(roles);
}
syncSelections() {
this.selectedRoles.forEach(s => this.selections.toggle(s.data, s.selected));
this.cdRef.markForCheck();
}
toggleAll() {
this.selectAll = !this.selectAll;
// Update selectedRoles considering disabled state
this.selectedRoles.filter(r => !r.disabled).forEach(r => r.selected = this.selectAll);
// Sync selections with updated selectedRoles
this.syncSelections();
this.selected.emit(this.selections.selected());
this.cdRef.markForCheck();
}
}

View file

@ -1,9 +1,9 @@
<ng-container *transloco="let t; read: 'all-series'">
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title>
<h4 title>
{{title}}
</h2>
<h6 subtitle *ngIf="pagination">{{t('series-count', {num: pagination.totalItems | number})}}</h6>
</h4>
<h5 subtitle *ngIf="pagination">{{t('series-count', {num: pagination.totalItems | number})}}</h5>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter"

View file

@ -5,7 +5,8 @@
<span class="badge bg-secondary">{{t('installed')}}</span>
{{t('description-continued', {installed: ''})}}
</p>
<ng-container *ngFor="let update of updates; let indx = index;">
@for(update of updates; track update; let indx = $index) {
<div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body">
<h4 class="card-title">{{update.updateTitle}}&nbsp;
@ -23,11 +24,16 @@
<pre class="card-text update-body">
<app-read-more [text]="update.updateBody" [maxLength]="500"></app-read-more>
</pre>
<a *ngIf="!update.isDocker && update.updateVersion === update.currentVersion" href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('installed')}}</a>
<a *ngIf="!update.isDocker && update.updateVersion !== update.currentVersion" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('download')}}</a>
@if (!update.isDocker && (accountService.isAdmin$ | async)) {
@if (update.updateVersion === update.currentVersion) {
<a href="{{update.updateUrl}}" class="btn disabled btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('installed')}}</a>
} @else {
<a href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-end" target="_blank" rel="noopener noreferrer">{{t('download')}}</a>
}
}
</div>
</div>
</ng-container>
}
</div>

View file

@ -3,21 +3,24 @@ import {UpdateVersionEvent} from 'src/app/_models/events/update-version-event';
import {ServerService} from 'src/app/_services/server.service';
import {LoadingComponent} from '../../../shared/loading/loading.component';
import {ReadMoreComponent} from '../../../shared/read-more/read-more.component';
import {DatePipe, NgFor, NgIf} from '@angular/common';
import {AsyncPipe, DatePipe} from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
import {AccountService} from "../../../_services/account.service";
@Component({
selector: 'app-changelog',
templateUrl: './changelog.component.html',
styleUrls: ['./changelog.component.scss'],
standalone: true,
imports: [NgFor, NgIf, ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective],
imports: [ReadMoreComponent, LoadingComponent, DatePipe, TranslocoDirective, AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangelogComponent implements OnInit {
private readonly serverService = inject(ServerService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly accountService = inject(AccountService);
updates: Array<UpdateVersionEvent> = [];
isLoading: boolean = true;

View file

@ -11,13 +11,8 @@ const routes: Routes = [
runGuardsAndResolvers: 'always',
children: [
{
path: 'admin',
canActivate: [AdminGuard],
loadChildren: () => import('./_routes/admin-routing.module').then(m => m.routes)
},
{
path: 'preferences',
loadChildren: () => import('./_routes/user-settings-routing.module').then(m => m.routes)
path: 'settings',
loadChildren: () => import('./_routes/settings-routing.module').then(m => m.routes)
},
{
path: 'collections',
@ -29,7 +24,6 @@ const routes: Routes = [
},
{
path: 'announcements',
canActivate: [AdminGuard],
loadChildren: () => import('./_routes/announcements-routing.module').then(m => m.routes)
},
{

View file

@ -1,18 +1,38 @@
<div [ngClass]="{'no-transitions' : (transitionState$ | async)}">
<app-nav-header></app-nav-header>
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
<a id="content"></a>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
<div class="container-fluid" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}">
<div style="padding: 20px 0 0;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false}">
<router-outlet></router-outlet>
</div>
</div>
<ng-template #noSideNav>
<router-outlet></router-outlet>
</ng-template>
@if (accountService.currentUser$ | async; as currentUser) {
@if (currentUser) {
<div class="fullpage-background">
<div class="background-area">
<canvas id="backgroundCanvas" class="default-background" style="width: 100%; height: calc(var(--vh) * 100);"></canvas>
</div>
</div>
}
}
<app-nav-header></app-nav-header>
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
@if (navService.sideNavVisibility$ | async) {
@if(navService.usePreferenceSideNav$ | async) {
<app-preference-nav></app-preference-nav>
} @else {
<app-side-nav></app-side-nav>
}
}
<div class="container-fluid" [ngClass]="{'g-0': (navService.sideNavVisibility$ | async) === false}">
<a id="content"></a>
@if (navService.sideNavVisibility$ | async) {
<div>
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService.sideNavCollapsed$ | async) === false || (navService.usePreferenceSideNav$ | async),
'companion-bar-collapsed': ((navService.sideNavCollapsed$ | async) && (navService.usePreferenceSideNav$ | async))}">
<router-outlet></router-outlet>
</div>
</div>
} @else {
<router-outlet></router-outlet>
}
</div>
</div>
</div>

View file

@ -8,6 +8,10 @@
margin-left: 40px;
}
.companion-bar-collapsed {
margin-left: 0 !important;
}
.companion-bar-content {
margin-left: 190px;
width: auto;
@ -21,7 +25,7 @@
.content-wrapper {
padding: 0 5px 0;
overflow: hidden;
height: calc(var(--vh)*100 - 56px);
height: calc(var(--vh)*100 - var(--nav-offset));
&.closed {
overflow: auto;
@ -29,12 +33,48 @@
}
.companion-bar {
margin-left: 0px;
padding-left: 0px;
margin-left: 0;
padding-left: 0;
}
.companion-bar-content {
margin-left: 0px;
margin-left: 0;
width: auto;
}
}
.default-background {
background: radial-gradient(circle farthest-side at 0% 100%,
var(--colorscape-darker-color) 0%,
var(--colorscape-darker-alpha-color) 100%),
radial-gradient(circle farthest-side at 100% 100%,
var(--colorscape-primary-color) 0%,
var(--colorscape-primary-alpha-color) 100%),
radial-gradient(circle farthest-side at 100% 0%,
var(--colorscape-lighter-color) 0%,
var(--colorscape-lighter-alpha-color) 100%),
radial-gradient(circle farthest-side at 0% 0%,
var(--colorscape-complementary-color) 0%,
var(--colorscape-complementary-alpha-color) 100%),
var(--bs-body-bg);
}
.fullpage-background {
position: fixed; /* Make sure it's fixed to the viewport */
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: -1;
pointer-events: none;
background-color: #121212;
.background-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 113vh;
}
}

View file

@ -1,26 +1,36 @@
import {ChangeDetectorRef, Component, DestroyRef, HostListener, inject, Inject, OnInit} from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
HostListener,
inject,
Inject,
OnInit
} from '@angular/core';
import {NavigationStart, Router, RouterOutlet} from '@angular/router';
import {map, shareReplay, take, tap} from 'rxjs/operators';
import { AccountService } from './_services/account.service';
import { LibraryService } from './_services/library.service';
import { NavService } from './_services/nav.service';
import { filter } from 'rxjs/operators';
import {AccountService} from './_services/account.service';
import {LibraryService} from './_services/library.service';
import {NavService} from './_services/nav.service';
import {NgbModal, NgbModalConfig, NgbOffcanvas, NgbRatingConfig} from '@ng-bootstrap/ng-bootstrap';
import { DOCUMENT, NgClass, NgIf, AsyncPipe } from '@angular/common';
import {interval, Observable, switchMap} from 'rxjs';
import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common';
import {filter, interval, Observable, switchMap} from 'rxjs';
import {ThemeService} from "./_services/theme.service";
import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.component';
import {SideNavComponent} from './sidenav/_components/side-nav/side-nav.component';
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ServerService} from "./_services/server.service";
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: true,
imports: [NgClass, NgIf, SideNavComponent, RouterOutlet, AsyncPipe, NavHeaderComponent]
imports: [NgClass, SideNavComponent, RouterOutlet, AsyncPipe, NavHeaderComponent, PreferenceNavComponent],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements OnInit {
@ -28,14 +38,19 @@ export class AppComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly offcanvas = inject(NgbOffcanvas);
public readonly navService = inject(NavService);
public readonly cdRef = inject(ChangeDetectorRef);
public readonly serverService = inject(ServerService);
protected readonly navService = inject(NavService);
protected readonly utilityService = inject(UtilityService);
protected readonly serverService = inject(ServerService);
protected readonly accountService = inject(AccountService);
private readonly libraryService = inject(LibraryService);
private readonly ngbModal = inject(NgbModal);
private readonly router = inject(Router);
private readonly themeService = inject(ThemeService);
constructor(private accountService: AccountService,
private libraryService: LibraryService,
private router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService, private modalConfig: NgbModalConfig) {
protected readonly Breakpoint = Breakpoint;
constructor(ratingConfig: NgbRatingConfig, @Inject(DOCUMENT) private document: Document, modalConfig: NgbModalConfig) {
modalConfig.fullscreen = 'md';
@ -44,7 +59,7 @@ export class AppComponent implements OnInit {
ratingConfig.resettable = true;
// Close any open modals when a route change occurs
router.events
this.router.events
.pipe(
filter(event => event instanceof NavigationStart),
takeUntilDestroyed(this.destroyRef)
@ -70,9 +85,6 @@ export class AppComponent implements OnInit {
this.transitionState$ = this.accountService.currentUser$.pipe(
tap(user => {
}),
map((user) => {
if (!user) return false;
return user.preferences.noTransitions;
@ -90,6 +102,7 @@ export class AppComponent implements OnInit {
ngOnInit(): void {
this.setDocHeight();
this.setCurrentUser();
this.themeService.setColorScape('');
}
setCurrentUser() {

View file

@ -1,9 +1,9 @@
<ng-container *transloco="let t; read: 'bookmarks'">
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title>
<h4 title>
{{t('title')}}
</h2>
<h6 subtitle>{{t('series-count', {num: series.length | number})}}</h6>
</h4>
<h5 subtitle>{{t('series-count', {num: series.length | number})}}</h5>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter"

View file

@ -6,7 +6,7 @@
</div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body">
@if (lists.length >= 5) {
@if (lists.length >= MaxItems) {
<div class="mb-3">
<label for="filter" class="form-label">{{t('filter-label')}}</label>
<div class="input-group">

View file

@ -1,5 +1,5 @@
.clickable:hover, .clickable:focus {
background-color: lightgreen;
background-color: var(--list-group-hover-bg-color, --primary-color);
}
.collection {

View file

@ -35,6 +35,7 @@ export class BulkAddToCollectionComponent implements OnInit, AfterViewInit {
private readonly collectionService = inject(CollectionTagService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly MaxItems = 8;
@Input({required: true}) title!: string;
/**

View file

@ -14,7 +14,6 @@ import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, forkJoin, switchMap, tap} from 'rxjs';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component';
import {UserCollection} from 'src/app/_models/collection-tag';
import {Pagination} from 'src/app/_models/pagination';
import {Series} from 'src/app/_models/series';
@ -24,12 +23,11 @@ import {LibraryService} from 'src/app/_services/library.service';
import {SeriesService} from 'src/app/_services/series.service';
import {UploadService} from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule, DatePipe, DecimalPipe, NgIf, NgTemplateOutlet} from "@angular/common";
import {DatePipe, DecimalPipe, NgIf, NgTemplateOutlet} from "@angular/common";
import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ScrobbleError} from "../../../_models/scrobbling/scrobble-error";
import {AccountService} from "../../../_services/account.service";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {ReadMoreComponent} from "../../../shared/read-more/read-more.component";
@ -38,6 +36,7 @@ import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
import {SelectionModel} from "../../../typeahead/_models/selection-model";
enum TabID {

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