Localization - First Pass (#2174)

* Started designing the backend localization service

* Worked in Transloco for initial PoC

* Worked in Transloco for initial PoC

* Translated the login screen

* translated dashboard screen

* Started work on the backend

* Fixed a logic bug

* translated edit-user screen

* Hooked up the backend for having a locale property.

* Hooked up the ability to view the available locales and switch to them.

* Made the localization service languages be derived from what's in langs/ directory.

* Fixed up localization switching

* Switched when we check for a license on UI bootstrap

* Tweaked some code

* Fixed the bug where dashboard wasn't loading and made it so language switching is working.

* Fixed a bug on dashboard with languagePath

* Converted user-scrobble-history.component.html

* Converted spoiler.component.html

* Converted review-series-modal.component.html

* Converted review-card-modal.component.html

* Updated the readme

* Translated using Weblate (English)

Currently translated at 100.0% (54 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/en/

* Converted review-card.component.html

* Deleted dead component

* Converted want-to-read.component.html

* Added translation using Weblate (Korean)

* Translated using Weblate (Spanish)

Currently translated at 40.7% (22 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/es/

* Translated using Weblate (Korean)

Currently translated at 62.9% (34 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-preferences.component.html

* Translated using Weblate (Korean)

Currently translated at 92.5% (50 of 54 strings)

Translation: Kavita/ui
Translate-URL: https://hosted.weblate.org/projects/kavita/ui/ko/

* Converted user-holds.component.html

* Converted theme-manager.component.html

* Converted restriction-selector.component.html

* Converted manage-devices.component.html

* Converted edit-device.component.html

* Converted change-password.component.html

* Converted change-email.component.html

* Converted change-age-restriction.component.html

* Converted api-key.component.html

* Converted anilist-key.component.html

* Converted typeahead.component.html

* Converted user-stats-info-cards.component.html

* Converted user-stats.component.html

* Converted top-readers.component.html

* Converted some pipes and ensure translation is loaded before the app.

* Finished all but one pipe for localization

* Converted directory-picker.component.html

* Converted library-access-modal.component.html

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Converted a few components

* Merged weblate in

* ... -> … update

* Updated the readme

* Updateded all fonts to be woff2

* Cleaned up some strings to increase re-use

* Removed an old flow (that doesn't exist in backend any longer) from when we introduced emails on Kavita.

* Converted Series detail

* Lots more converted

* Lots more converted & hooked up the ability to flatten during prod build the language files.

* Lots more converted

* Lots more converted & fixed a bunch of broken pipes due to inject()

* Lots more converted

* Lots more converted

* Lots more converted & fixed some bad keys

* Lots more converted

* Fixed some bugs with admin dasbhoard nested tabs not rendering on first load due to not using onpush change detection

* Fixed up some localization errors and fixed forgot password error when the user doesn't have change password permission

* Fixed a stupid build issue again

* Started adding errors for interceptor and backend.

* Finished off manga-reader

* More translations

* Few fixes

* Fixed a bug where character tag badges weren't showing the name on chapter info

* All components are translated

* All toasts are translated

* All confirm/alerts are translated

* Trying something new for the backend

* Migrated the localization strings for the backend into a new file.

* Updated the localization service to be able to do backend localization with fallback to english.

* Cleaned up some external reviews code to reduce looping

* Localized AccountController.cs

* 60% done with controllers

* All controllers are done

* All KavitaExceptions are covered

* Some shakeout fixes

* Prep for initial merge

* Everything is done except options and basic shakeout proves response times are good. Unit tests are broken.

* Fixed up the unit tests

* All unit tests are now working

* Removed some quantifier

* I'm not sure I can support localization for some Volume/Chapter/Book strings within the codebase.

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: expertjun <jtrobin@naver.com>
Co-authored-by: ThePromidius <thepromidiusyt@gmail.com>
This commit is contained in:
Joe Milazzo 2023-08-03 10:33:51 -05:00 committed by GitHub
parent 670bf82c38
commit 3b23d63234
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
389 changed files with 13652 additions and 7925 deletions

View file

@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import {inject, Pipe, PipeTransform} from '@angular/core';
import { DevicePlatform } from 'src/app/_models/device/device-platform';
import {TranslocoService} from "@ngneat/transloco";
@Pipe({
name: 'devicePlatform',
@ -7,12 +8,14 @@ import { DevicePlatform } from 'src/app/_models/device/device-platform';
})
export class DevicePlatformPipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(value: DevicePlatform): string {
switch(value) {
case DevicePlatform.Kindle: return 'Kindle';
case DevicePlatform.Kobo: return 'Kobo';
case DevicePlatform.PocketBook: return 'PocketBook';
case DevicePlatform.Custom: return 'Custom';
case DevicePlatform.Custom: return this.translocoService.translate('device.platform-pipe.custom');
default: return value + '';
}
}

View file

@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core';
import {inject, Pipe, PipeTransform} from '@angular/core';
import { ThemeProvider } from 'src/app/_models/preferences/site-theme';
import {TranslocoService} from "@ngneat/transloco";
@Pipe({
@ -8,13 +9,15 @@ import { ThemeProvider } from 'src/app/_models/preferences/site-theme';
})
export class SiteThemeProviderPipe implements PipeTransform {
translocoService = inject(TranslocoService);
transform(provider: ThemeProvider | undefined | null): string {
if (provider === null || provider === undefined) return '';
switch(provider) {
case ThemeProvider.System:
return 'System';
return this.translocoService.translate('site-theme-provider-pipe.system');
case ThemeProvider.User:
return 'User';
return this.translocoService.translate('site-theme-provider-pipe.user');
default:
return '';
}

View file

@ -1,51 +1,53 @@
<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-11"><h4 id="anilist-token-header">Scrobbling Providers</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
<ng-container *transloco="let t; read:'scrobbling-providers'">
<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-11"><h4 id="anilist-token-header">{{t('title')}}</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
</div>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">
<ng-container *ngIf="!hasValidLicense; else showToken">
This feature requires an active <a href="" target="_blank" rel="noreferrer nofollow">Kavita+</a> license
{{t('requires', {product: 'Kavita+'})}}
</ng-container>
<ng-template #showToken>
<ng-container *ngIf="token && token.length > 0; else noToken">
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList"> Token Set
<i class="error fa-solid fa-exclamation-circle" ngbTooltip="Token Expired" *ngIf="tokenExpired">
<span class="visually-hidden">Token Expired</span>
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList"> {{t('token-set')}}
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')" *ngIf="tokenExpired">
<span class="visually-hidden">{{t('token-expired')}}</span>
</i>
</ng-container>
<ng-template #noToken>No Token Set</ng-template>
<ng-template #noToken>{{t('no-token-set')}}</ng-template>
</ng-template>
</span>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<p>First time users should click on "Generate" below to allow KavitaPlus to talk with Anilist.
Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.</p>
<form [formGroup]="formGroup">
<div class="form-group mb-3">
<label for="anilist-token">AniList Token Goes Here</label>
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<a class="flex-fill btn btn-secondary me-2"
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
target="_blank" rel="noopener noreferrer">Generate</a>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">Save</button>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<p>{{t('instructions', {service: 'AniList'})}}</p>
<form [formGroup]="formGroup">
<div class="form-group mb-3">
<label for="anilist-token">{{t('token-input-label', {service: 'AniList'})}}</label>
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
</div>
</form>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<a class="flex-fill btn btn-secondary me-2"
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
target="_blank" rel="noopener noreferrer">{{t('generate')}}</a>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">{{t('save')}}</button>
</div>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -14,6 +14,7 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se
import {AccountService} from "../../_services/account.service";
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgOptimizedImage } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-anilist-key',
@ -21,7 +22,7 @@ import { NgIf, NgOptimizedImage } from '@angular/common';
styleUrls: ['./anilist-key.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgOptimizedImage, NgbTooltip, NgbCollapse, ReactiveFormsModule]
imports: [NgIf, NgOptimizedImage, NgbTooltip, NgbCollapse, ReactiveFormsModule, TranslocoModule]
})
export class AnilistKeyComponent implements OnInit {
@ -65,7 +66,7 @@ export class AnilistKeyComponent implements OnInit {
saveForm() {
this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => {
this.toastr.success('AniList Token has been updated');
this.toastr.success(translate('toasts.anilist-token-updated'));
this.token = this.formGroup.get('aniListToken')!.value;
this.resetForm();
this.isViewMode = true;

View file

@ -1,14 +1,16 @@
<div class="mb-3">
<label for="api-key--{{title}}" class="form-label">{{title}}</label><span *ngIf="tooltipText.length > 0">&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i></span>
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey type="text" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div id="button-addon4">
<button class="btn btn-outline-secondary" type="button" (click)="copy()" title="Copy"><span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
<button class="btn btn-danger" type="button" [ngbTooltip]="tipContent" (click)="refresh()" *ngIf="showRefresh"><span class="visually-hidden">Regenerate</span><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
</div>
<ng-template #tipContent>
Regenerating your API key will invalidate any existing clients.
</ng-template>
</div>
</div>
<ng-container *transloco="let t; read:'api-key'">
<div class="mb-3">
<label for="api-key--{{title}}" class="form-label">{{title}}</label><span *ngIf="tooltipText.length > 0">&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i></span>
<ng-template #tooltip>{{tooltipText}}</ng-template>
<div class="input-group">
<input #apiKey type="text" readonly class="form-control" id="api-key--{{title}}" aria-describedby="button-addon4" [value]="key" (click)="selectAll()">
<div id="button-addon4">
<button class="btn btn-outline-secondary" type="button" (click)="copy()" [title]="t('copy')"><span class="visually-hidden">Copy</span><i class="fa fa-copy" aria-hidden="true"></i></button>
<button class="btn btn-danger" type="button" [ngbTooltip]="tipContent" (click)="refresh()" *ngIf="showRefresh"><span class="visually-hidden">Regenerate</span><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
</div>
<ng-template #tipContent>
{{t('regen-warning')}}
</ng-template>
</div>
</div>
</ng-container>

View file

@ -14,6 +14,7 @@ import {Clipboard} from '@angular/cdk/clipboard';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf } from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
@Component({
selector: 'app-api-key',
@ -21,7 +22,7 @@ import { NgIf } from '@angular/common';
styleUrls: ['./api-key.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip]
imports: [NgIf, NgbTooltip, TranslocoModule]
})
export class ApiKeyComponent implements OnInit {
@ -32,6 +33,7 @@ export class ApiKeyComponent implements OnInit {
@ViewChild('apiKey') inputElem!: ElementRef;
key: string = '';
private readonly destroyRef = inject(DestroyRef);
private readonly translocoService = inject(TranslocoService);
constructor(private confirmService: ConfirmService, private accountService: AccountService, private toastr: ToastrService, private clipboard: Clipboard,
@ -43,7 +45,7 @@ export class ApiKeyComponent implements OnInit {
if (user) {
key = user.apiKey;
} else {
key = 'ERROR - KEY NOT SET';
key = this.translocoService.translate('api-key.no-key');
}
if (this.transform != undefined) {
@ -61,13 +63,13 @@ export class ApiKeyComponent implements OnInit {
}
async refresh() {
if (!await this.confirmService.confirm('This will invalidate any OPDS configurations you have setup. Are you sure you want to continue?')) {
if (!await this.confirmService.confirm(this.translocoService.translate('api-key.confirm-reset'))) {
return;
}
this.accountService.resetApiKey().subscribe(newKey => {
this.key = newKey;
this.cdRef.markForCheck();
this.toastr.success('API Key reset');
this.toastr.success(this.translocoService.translate('api-key.key-reset'));
});
}

View file

@ -1,38 +1,41 @@
<div class="card mt-2">
<ng-container *transloco="let t; read:'change-age-restriction'">
<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-11"><h4 id="age-restriction">Age Restriction</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangeAgeRestrictionAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-11"><h4 id="age-restriction">{{t('age-restriction-label')}}</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangeAgeRestrictionAbility | async)">{{isViewMode ? t('edit') : t('cancel')}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">{{user?.ageRestriction?.ageRating| ageRating | async}}
<ng-container *ngIf="user?.ageRestriction?.ageRating !== AgeRating.NotApplicable && user?.ageRestriction?.includeUnknowns">
<span class="ms-1 me-1">+</span> Unknowns
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">{{user?.ageRestriction?.ageRating | ageRating }}
<ng-container *ngIf="user?.ageRestriction?.ageRating !== AgeRating.NotApplicable && user?.ageRestriction?.includeUnknowns">
<span class="ms-1 me-1">+</span> {{t('unknowns')}}
</ng-container>
</span>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container *ngIf="user">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [showContext]="false" [member]="user" [reset]="reset"></app-restriction-selector>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="age-restriction" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="age-restriction" (click)="saveForm()">Save</button>
</div>
</ng-container>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container *ngIf="user">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [showContext]="false" [member]="user" [reset]="reset"></app-restriction-selector>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="age-restriction" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="age-restriction" (click)="saveForm()">{{t('save')}}</button>
</div>
</ng-container>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -4,11 +4,10 @@ import {
Component, DestroyRef,
EventEmitter,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { Observable, of, Subject, takeUntil, shareReplay, map, take } from 'rxjs';
import { Observable, of, shareReplay, map, take } from 'rxjs';
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { User } from 'src/app/_models/user';
@ -18,6 +17,7 @@ import { AgeRatingPipe } from '../../pipe/age-rating.pipe';
import { RestrictionSelectorComponent } from '../restriction-selector/restriction-selector.component';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, AsyncPipe } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-change-age-restriction',
@ -25,7 +25,7 @@ import { NgIf, AsyncPipe } from '@angular/common';
styleUrls: ['./change-age-restriction.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, RestrictionSelectorComponent, AsyncPipe, AgeRatingPipe]
imports: [NgIf, NgbCollapse, RestrictionSelectorComponent, AsyncPipe, AgeRatingPipe, TranslocoModule]
})
export class ChangeAgeRestrictionComponent implements OnInit {
@ -69,7 +69,7 @@ export class ChangeAgeRestrictionComponent implements OnInit {
if (this.user === undefined) { return; }
this.accountService.updateAgeRestriction(this.selectedRestriction.ageRating, this.selectedRestriction.includeUnknowns).subscribe(() => {
this.toastr.success('Age Restriction has been updated');
this.toastr.success(translate('toasts.age-restriction-updated'));
this.originalRestriction = this.selectedRestriction;
if (this.user) {
this.user.ageRestriction.ageRating = this.selectedRestriction.ageRating;

View file

@ -1,79 +1,80 @@
<div class="card mt-2">
<ng-container *transloco="let t; read:'change-email'">
<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-11">
<h4 id="email-card">Email
<ng-container *ngIf="!emailConfirmed">
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" ngbTooltip="This email is not confirmed"></i>
<span class="visually-hidden">This email is not confirmed</span>
</ng-container>
</h4>
</div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-11">
<h4 id="email-card">{{t('email-label')}}
<ng-container *ngIf="!emailConfirmed">
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
<span class="visually-hidden">{{t('email-not-confirmed')}}</span>
</ng-container>
</h4>
</div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">{{user?.email}}</span>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">{{user?.email}}</span>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="errors.length > 0">
<div *ngFor="let error of errors">{{error}}</div>
</div>
<form [formGroup]="form">
<div class="mb-3">
<label for="email" class="form-label visually-hidden">{{t('email-label')}}</label>
<input class="form-control custom-input" type="email" id="email" formControlName="email"
[class.is-invalid]="form.get('email')?.invalid && form.get('email')?.touched">
<div id="email-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
<div *ngIf="form.get('email')?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">{{t('current-password-label')}}</label>
<input class="form-control custom-input" type="password" id="password" formControlName="password"
[class.is-invalid]="form.get('password')?.invalid && form.get('password')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
<div *ngIf="form.get('password')?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="email-card" (click)="resetForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="email-card" (click)="saveForm()" [disabled]="!form.valid || !(form.dirty || form.touched)">{{t('save')}}</button>
</div>
</form>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="errors.length > 0">
<div *ngFor="let error of errors">{{error}}</div>
</div>
<form [formGroup]="form">
<div class="mb-3">
<label for="email" class="form-label visually-hidden">Email</label>
<input class="form-control custom-input" type="email" id="email" formControlName="email"
[class.is-invalid]="form.get('email')?.invalid && form.get('email')?.touched">
<div id="email-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
<div *ngIf="form.get('email')?.errors?.required">
This field is required
</div>
</div>
</div>
<ng-container *ngIf="emailLink !== ''">
<h4>{{t('email-updated-title')}}</h4>
<p>{{t('email-updated-description')}}</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('invite-url-tooltip')" [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
<div class="mb-3">
<label for="password" class="form-label">Current Password</label>
<input class="form-control custom-input" type="password" id="password" formControlName="password"
[class.is-invalid]="form.get('password')?.invalid && form.get('password')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
<div *ngIf="form.get('password')?.errors?.required">
This field is required
</div>
</div>
</div>
<ng-template #noPermission>
<p>{{t('permission-error')}}</p>
</ng-template>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="email-card" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="email-card" (click)="saveForm()" [disabled]="!form.valid || !(form.dirty || form.touched)">Save</button>
</div>
</form>
</ng-container>
<ng-container *ngIf="emailLink !== ''">
<h4>Email Updated</h4>
<p>You can use the following link below to confirm the email for your account.
If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
<ng-template #noPermission>
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
</ng-template>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -9,6 +9,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { ApiKeyComponent } from '../api-key/api-key.component';
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-change-email',
@ -16,7 +17,7 @@ import { NgIf, NgFor } from '@angular/common';
styleUrls: ['./change-email.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbTooltip, NgbCollapse, NgFor, ReactiveFormsModule, ApiKeyComponent]
imports: [NgIf, NgbTooltip, NgbCollapse, NgFor, ReactiveFormsModule, ApiKeyComponent, TranslocoModule]
})
export class ChangeEmailComponent implements OnInit {
@ -61,12 +62,12 @@ export class ChangeEmailComponent implements OnInit {
this.accountService.updateEmail(model.email, model.password).subscribe((updateEmailResponse: UpdateEmailResponse) => {
if (updateEmailResponse.emailSent) {
if (updateEmailResponse.hadNoExistingEmail) {
this.toastr.success('An email has been sent to ' + model.email + ' for confirmation.');
this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email}));
} else {
this.toastr.success('An email has been sent to your old email address for confirmation');
this.toastr.success(translate('toasts.email-send-to'));
}
} else {
this.toastr.success('The server is not publicly accessible. Ask the admin to fetch your confirmation link from the logs');
this.toastr.success(translate('toasts.change-email-private'));
}
this.isViewMode = true;

View file

@ -1,75 +1,73 @@
<div class="card mt-2">
<ng-container *transloco="let t; read:'change-password'">
<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-11"><h4>Password</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangePasswordAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-11"><h4>{{t('password-label')}}</h4></div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangePasswordAbility | async)">{{isViewMode ? t('edit') : t('cancel')}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">***************</span>
<ng-container *ngIf="isViewMode">
<div class="container-fluid row">
<span class="col-12">***************</span>
</div>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="mb-3">
<label for="oldpass" class="form-label">{{t('current-password-label')}}</label>
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
<div class="mb-3">
<label for="new-password">{{t('new-password-label')}}</label>
<input class="form-control" type="password" id="new-password" formControlName="password" aria-describedby="new-password-validations"
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
<div id="new-password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
<div class="mb-3">
<label for="confirm-password">{{t('confirm-password-label')}}</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="confirm-password-validations"
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
<div id="confirm-password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
{{t('passwords-must-match')}}
</div>
<div *ngIf="confirmPassword?.errors?.required">
{{t('required-field')}}
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">{{t('save')}}</button>
</div>
</form>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="mb-3">
<label for="oldpass" class="form-label">Current Password</label>
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="new-password">New Password</label>
<input class="form-control" type="password" id="new-password" formControlName="password" aria-describedby="new-password-validations"
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
<div id="new-password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="confirm-password">Confirm Password</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="confirm-password-validations"
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
<div id="confirm-password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
Passwords must match
</div>
<div *ngIf="confirmPassword?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
</div>
</form>
</ng-container>
<ng-template #noPermission>
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
</ng-template>
</div>
<ng-template #noPermission>
<p>{{t('permission-error')}}</p>
</ng-template>
</div>
</div>
</div>
</div>
</ng-container>

View file

@ -15,6 +15,7 @@ import { AccountService } from 'src/app/_services/account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-change-password',
@ -22,7 +23,7 @@ import { NgIf, NgFor, AsyncPipe } from '@angular/common';
styleUrls: ['./change-password.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgbCollapse, NgFor, ReactiveFormsModule, AsyncPipe]
imports: [NgIf, NgbCollapse, NgFor, ReactiveFormsModule, AsyncPipe, TranslocoModule]
})
export class ChangePasswordComponent implements OnInit, OnDestroy {
@ -82,7 +83,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
const model = this.passwordChangeForm.value;
this.resetPasswordErrors = [];
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
this.toastr.success('Password has been updated');
this.toastr.success(translate('toasts.password-updated'));
this.resetPasswordForm();
this.isViewMode = true;
}, err => {

View file

@ -1,49 +1,53 @@
<div class="card">
<ng-container *transloco="let t; read:'edit-device'">
<div class="card">
<form [formGroup]="settingsForm" class="card-body">
<div class="row g-0 mb-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="settings-name" class="form-label">Device Name</label>
<input id="settings-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="settingsForm.get('name')?.invalid && settingsForm.get('name')?.touched">
<ng-container *ngIf="settingsForm.get('name')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="email" class="form-label">Email</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
<ng-template #emailTooltip>This email will be used to accept the file via Send To</ng-template>
<span class="visually-hidden" id="email-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
<input id="email" aria-describedby="email-help"
class="form-control" formControlName="email" type="email" inputmode="email"
placeholder="id@kindle.com" [class.is-invalid]="settingsForm.get('email')?.invalid && settingsForm.get('email')?.touched">
<ng-container *ngIf="settingsForm.get('email')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.email">
This must be a valid email
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="device-platform" class="form-label">Device Platform</label>
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform"
[class.is-invalid]="settingsForm.get('platform')?.invalid && settingsForm.get('platform')?.touched">
<option *ngFor="let patform of devicePlatforms" [value]="patform">{{patform | devicePlatform}}</option>
</select>
<ng-container *ngIf="settingsForm.get('platform')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="row g-0 mb-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="settings-name" class="form-label">{{t('device-name-label')}}</label>
<input id="settings-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="settingsForm.get('name')?.invalid && settingsForm.get('name')?.touched">
<ng-container *ngIf="settingsForm.get('name')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
{{t('required-field')}}
</p>
</ng-container>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="submit" class="flex-fill btn btn-primary" (click)="addDevice()" [disabled]="!settingsForm.dirty">Save</button>
<div class="col-md-3 col-sm-12 pe-2">
<label for="email" class="form-label">{{t('email-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
<ng-template #emailTooltip>{{t('email-tooltip')}}</ng-template>
<span class="visually-hidden" id="email-help">
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
</span>
<input id="email" aria-describedby="email-help"
class="form-control" formControlName="email" type="email" inputmode="email"
placeholder="id@kindle.com" [class.is-invalid]="settingsForm.get('email')?.invalid && settingsForm.get('email')?.touched">
<ng-container *ngIf="settingsForm.get('email')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.email">
{{t('valid-email')}}
</p>
<p class="invalid-feedback" *ngIf="errors.required">
{{t('required-field')}}
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="device-platform" class="form-label">{{t('device-platform-label')}}</label>
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform"
[class.is-invalid]="settingsForm.get('platform')?.invalid && settingsForm.get('platform')?.touched">
<option *ngFor="let platform of devicePlatforms" [value]="platform">{{platform | devicePlatform}}</option>
</select>
<ng-container *ngIf="settingsForm.get('platform')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
{{t('required-field')}}
</p>
</ng-container>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="submit" class="flex-fill btn btn-primary" (click)="addDevice()" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</form>
</div>
</div>
</ng-container>

View file

@ -6,21 +6,20 @@ import {
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges
} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, takeUntil } from 'rxjs';
import { Device } from 'src/app/_models/device/device';
import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform';
import { DeviceService } from 'src/app/_services/device.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { DevicePlatformPipe } from '../_pipes/device-platform.pipe';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor } from '@angular/common';
import {NgIf, NgFor, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-edit-device',
@ -28,7 +27,7 @@ import { NgIf, NgFor } from '@angular/common';
styleUrls: ['./edit-device.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ReactiveFormsModule, NgIf, NgbTooltip, NgFor, DevicePlatformPipe]
imports: [ReactiveFormsModule, NgIf, NgbTooltip, NgFor, DevicePlatformPipe, TranslocoModule, NgTemplateOutlet]
})
export class EditDeviceComponent implements OnInit, OnChanges {
@ -76,7 +75,7 @@ export class EditDeviceComponent implements OnInit, OnChanges {
if (this.device !== undefined) {
this.deviceService.updateDevice(this.device.id, this.settingsForm.value.name, parseInt(this.settingsForm.value.platform, 10), this.settingsForm.value.email).subscribe(() => {
this.settingsForm.reset();
this.toastr.success('Device updated');
this.toastr.success(translate('toasts.device-updated'));
this.cdRef.markForCheck();
this.deviceUpdated.emit();
})
@ -85,7 +84,7 @@ export class EditDeviceComponent implements OnInit, OnChanges {
this.deviceService.createDevice(this.settingsForm.value.name, parseInt(this.settingsForm.value.platform, 10), this.settingsForm.value.email).subscribe(() => {
this.settingsForm.reset();
this.toastr.success('Device created');
this.toastr.success(translate('toasts.device-created'));
this.cdRef.markForCheck();
this.deviceAdded.emit();
});

View file

@ -1,39 +1,42 @@
<div class="container-fluid">
<ng-container *transloco="let t; read:'manage-devices'">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Device Manager</h3></div>
<div class="col-4">
<button class="btn btn-primary float-end" (click)="collapse.toggle()" [attr.aria-expanded]="!addDeviceIsCollapsed"
aria-controls="collapseExample">
<i class="fa fa-plus" aria-hidden="true"></i>&nbsp;Add
</button>
</div>
<div class="col-8"><h3>{{t('title')}}</h3></div>
<div class="col-4">
<button class="btn btn-primary float-end" (click)="collapse.toggle()" [attr.aria-expanded]="!addDeviceIsCollapsed"
aria-controls="collapseExample">
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t('add')}}
</button>
</div>
</div>
<p>
This section is for you to setup devices that cannot connect to Kavita via a web browser and instead have an email address that accepts files.
<p>
{{t('description')}}
</p>
<div #collapse="ngbCollapse" [(ngbCollapse)]="addDeviceIsCollapsed">
<app-edit-device [device]="device" (deviceAdded)="loadDevices()" (deviceUpdated)="loadDevices()"></app-edit-device>
<app-edit-device [device]="device" (deviceAdded)="loadDevices()" (deviceUpdated)="loadDevices()"></app-edit-device>
</div>
<div class="row g-0 mt-2">
<h4>Devices</h4>
<p *ngIf="devices.length === 0">
There are no devices setup yet
</p>
<ng-container *ngFor="let device of devices">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{device.name | sentenceCase}}</h5>
Platform: <h6 class="card-subtitle mb-2 text-muted">{{device.platform | devicePlatform}}</h6>
Email: <h6 class="card-subtitle mb-2 text-muted">{{device.emailAddress}}</h6>
<h4>{{t('devices-title')}}</h4>
<p *ngIf="devices.length === 0">
{{t('no-devices')}}
</p>
<ng-container *ngFor="let device of devices">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{device.name | sentenceCase}}</h5>
{{t('platform-label')}}<h6 class="card-subtitle mb-2 text-muted">{{device.platform | devicePlatform}}</h6>
{{t('email-label')}}<h6 class="card-subtitle mb-2 text-muted">{{device.emailAddress}}</h6>
<button class="btn btn-danger me-2" (click)="deleteDevice(device)">Delete</button>
<button class="btn btn-primary" (click)="editDevice(device)">Edit</button>
</div>
</div>
</ng-container>
<button class="btn btn-danger me-2" (click)="deleteDevice(device)">{{t('delete')}}</button>
<button class="btn btn-primary" (click)="editDevice(device)">{{t('edit')}}</button>
</div>
</div>
</ng-container>
</div>
</div>
</div>
</ng-container>

View file

@ -1,15 +1,14 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Subject, takeUntil } from 'rxjs';
import { Subject } from 'rxjs';
import { Device } from 'src/app/_models/device/device';
import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform';
import { DeviceService } from 'src/app/_services/device.service';
import { DevicePlatformPipe } from '../_pipes/device-platform.pipe';
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
import { NgIf, NgFor } from '@angular/common';
import { EditDeviceComponent } from '../edit-device/edit-device.component';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-manage-devices',
@ -17,7 +16,7 @@ import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
styleUrls: ['./manage-devices.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgbCollapse, EditDeviceComponent, NgIf, NgFor, SentenceCasePipe, DevicePlatformPipe]
imports: [NgbCollapse, EditDeviceComponent, NgIf, NgFor, SentenceCasePipe, DevicePlatformPipe, TranslocoModule]
})
export class ManageDevicesComponent implements OnInit, OnDestroy {
@ -28,7 +27,7 @@ export class ManageDevicesComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(public deviceService: DeviceService, private toastr: ToastrService,
constructor(public deviceService: DeviceService, private toastr: ToastrService,
private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
@ -49,7 +48,7 @@ export class ManageDevicesComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
});
}
deleteDevice(device: Device) {
this.deviceService.deleteDevice(device.id).subscribe(() => {
const index = this.devices.indexOf(device);

View file

@ -1,31 +1,35 @@
<ng-container *ngIf="restrictionForm">
<ng-container *transloco="let t; read:'restriction-selector'">
<ng-container *ngIf="restrictionForm">
<ng-container *ngIf="showContext">
<h4>Age Rating Restriction</h4>
<p>When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.
<ng-container *ngIf="isAdmin">This is not applicable for admins.</ng-container>
</p>
<h4>{{t('title')}}</h4>
<p>{{t('description')}}
<ng-container *ngIf="isAdmin">{{t('not-applicable-for-admins')}}</ng-container>
</p>
</ng-container>
<form [formGroup]="restrictionForm">
<div class="mb-3">
<label for="age-rating" class="form-label visually-hidden">Age Rating</label>
<div class="input-group">
<select class="form-select"id="age-rating" formControlName="ageRating">
<option value="-1">No Restriction</option>
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
<div class="mb-3">
<label for="age-rating" class="form-label visually-hidden">{{t('age-rating-label')}}</label>
<div class="input-group">
<select class="form-select" id="age-rating" formControlName="ageRating">
<option value="-1">{{t('no-restriction')}}</option>
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="ageRestrictionIncludeUnknowns" class="form-check-input" aria-describedby="include-unknowns-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">{{t('include-unknowns-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="includeUnknownsTooltip" role="button" tabindex="0"></i>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="ageRestrictionIncludeUnknowns" class="form-check-input" aria-describedby="include-unknowns-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Include Unknowns</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="includeUnknownsTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #includeUnknownsTooltip>If true, Unknowns will be allowed with Age Restrcition. This could lead to untagged media leaking to users with Age restrictions.</ng-template>
<span class="visually-hidden" id="include-unknowns-help">If true, Unknowns will be allowed with Age Restrcition. This could lead to untagged media leaking to users with Age restrictions.</span>
</div>
<ng-template #includeUnknownsTooltip>{{t('include-unknowns-tooltip')}}</ng-template>
<span class="visually-hidden" id="include-unknowns-help">
<ng-container [ngTemplateOutlet]="includeUnknownsTooltip"></ng-container>
</span>
</div>
</form>
</ng-container>
</ng-container>
</ng-container>

View file

@ -7,7 +7,8 @@ import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
import { User } from 'src/app/_models/user';
import { MetadataService } from 'src/app/_services/metadata.service';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { NgIf, NgFor, TitleCasePipe } from '@angular/common';
import {NgIf, NgFor, TitleCasePipe, NgTemplateOutlet} from '@angular/common';
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-restriction-selector',
@ -15,7 +16,7 @@ import { NgIf, NgFor, TitleCasePipe } from '@angular/common';
styleUrls: ['./restriction-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, ReactiveFormsModule, NgFor, NgbTooltip, TitleCasePipe]
imports: [NgIf, ReactiveFormsModule, NgFor, NgbTooltip, TitleCasePipe, TranslocoModule, NgTemplateOutlet]
})
export class RestrictionSelectorComponent implements OnInit, OnChanges {
@ -27,7 +28,7 @@ export class RestrictionSelectorComponent implements OnInit, OnChanges {
@Input() showContext: boolean = true;
@Input() reset: EventEmitter<AgeRestriction> | undefined;
@Output() selected: EventEmitter<AgeRestriction> = new EventEmitter<AgeRestriction>();
ageRatings: Array<AgeRatingDto> = [];
restrictionForm: FormGroup | undefined;
@ -39,7 +40,7 @@ export class RestrictionSelectorComponent implements OnInit, OnChanges {
this.restrictionForm = new FormGroup({
'ageRating': new FormControl(this.member?.ageRestriction.ageRating || AgeRating.NotApplicable || AgeRating.NotApplicable, []),
'ageRestrictionIncludeUnknowns': new FormControl(this.member?.ageRestriction.includeUnknowns || false, []),
});
if (this.isAdmin) {

View file

@ -1,29 +1,32 @@
<div class="container-fluid">
<ng-container *transloco="let t; read:'theme-manager'">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Theme Manager</h3></div>
<div class="col-4" *ngIf="isAdmin">
<button class="btn btn-primary float-end" (click)="scan()">
<i class="fa fa-refresh" aria-hidden="true"></i>&nbsp;Scan
</button>
</div>
<div class="col-8"><h3>{{t('title')}}</h3></div>
<div class="col-4" *ngIf="isAdmin">
<button class="btn btn-primary float-end" (click)="scan()">
<i class="fa fa-refresh" aria-hidden="true"></i>&nbsp;{{t('scan')}}
</button>
</div>
</div>
<p *ngIf="isAdmin">
Looking for a light or e-ink theme? We have some custom themes you can use on our <a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">theme github</a>.
<p *ngIf="isAdmin">
{{t('looking-for-theme')}}<a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">{{t('looking-for-theme-continued')}}</a>
</p>
<div class="row g-0">
<h4>Site Themes</h4>
<ng-container *ngFor="let theme of (themeService.themes$ | async)">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{theme.name | sentenceCase}}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{theme.provider | siteThemeProvider}}</h6>
<button class="btn btn-secondary me-2" [disabled]="theme.isDefault" *ngIf="isAdmin" (click)="updateDefault(theme)">Set Default</button>
<button class="btn btn-primary" (click)="applyTheme(theme)" [disabled]="currentTheme?.id === theme.id">{{currentTheme?.id === theme.id ? 'Applied' : 'Apply'}}</button>
</div>
</div>
</ng-container>
<h4>{{t('site-themes')}}</h4>
<ng-container *ngFor="let theme of (themeService.themes$ | async)">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{theme.name | sentenceCase}}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{theme.provider | siteThemeProvider}}</h6>
<button class="btn btn-secondary me-2" [disabled]="theme.isDefault" *ngIf="isAdmin" (click)="updateDefault(theme)">{{t('set-default')}}</button>
<button class="btn btn-primary" (click)="applyTheme(theme)" [disabled]="currentTheme?.id === theme.id">{{currentTheme?.id === theme.id ? t('applied') : t('apply')}}</button>
</div>
</div>
</ng-container>
</div>
</div>
</div>
</ng-container>

View file

@ -4,11 +4,9 @@ import {
Component,
DestroyRef,
inject,
OnDestroy,
OnInit
} from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { distinctUntilChanged, Subject, take, takeUntil } from 'rxjs';
import { distinctUntilChanged, take } from 'rxjs';
import { ThemeService } from 'src/app/_services/theme.service';
import { SiteTheme, ThemeProvider } from 'src/app/_models/preferences/site-theme';
import { User } from 'src/app/_models/user';
@ -17,6 +15,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SiteThemeProviderPipe } from '../_pipes/site-theme-provider.pipe';
import { SentenceCasePipe } from '../../pipe/sentence-case.pipe';
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
@Component({
selector: 'app-theme-manager',
@ -24,7 +23,7 @@ import { NgIf, NgFor, AsyncPipe } from '@angular/common';
styleUrls: ['./theme-manager.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe]
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoModule]
})
export class ThemeManagerComponent {
@ -32,10 +31,8 @@ export class ThemeManagerComponent {
isAdmin: boolean = false;
user: User | undefined;
private readonly destroyRef = inject(DestroyRef);
private readonly translocService = inject(TranslocoService);
get ThemeProvider() {
return ThemeProvider;
}
constructor(public themeService: ThemeService, private accountService: AccountService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
@ -72,13 +69,13 @@ export class ThemeManagerComponent {
updateDefault(theme: SiteTheme) {
this.themeService.setDefault(theme.id).subscribe(() => {
this.toastr.success('Site default has been updated to ' + theme.name);
this.toastr.success(this.translocService.translate('theme-manager.updated-toastr', {name: theme.name}));
});
}
scan() {
this.themeService.scan().subscribe(() => {
this.toastr.info('A site theme scan has been queued');
this.toastr.info(this.translocService.translate('theme-manager.scan-queued'));
});
}
}

View file

@ -1,11 +1,13 @@
<h5>Scrobble Holds</h5>
<p>This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at
any time and the next Scrobble-able event (reading progress, rating, want to read status) will trigger events.</p>
<ng-container *transloco="let t; read:'user-holds'">
<h5>{{t('title')}}</h5>
<p>{{t('description')}}</p>
<div class="row g-0">
<ul class="list-group mb-2">
<li class="list-group-item list-group-item-light" *ngFor="let hold of holds$ | async">
<a class="btn-link" href="/library/{{hold.libraryId}}/series/{{hold.seriesId}}" target="_blank">{{hold.seriesName}}</a>
</li>
</ul>
</div>
<div class="row g-0">
<ul class="list-group mb-2">
<li class="list-group-item list-group-item-light" *ngFor="let hold of holds$ | async">
<a class="btn-link" href="/library/{{hold.libraryId}}/series/{{hold.seriesId}}" target="_blank">{{hold.seriesName}}</a>
</li>
</ul>
</div>
</ng-container>

View file

@ -11,11 +11,12 @@ import {
NgbAccordionDirective, NgbAccordionHeader,
NgbAccordionItem
} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoModule} from "@ngneat/transloco";
@Component({
selector: 'app-user-holds',
standalone: true,
imports: [CommonModule, ScrobbleEventTypePipe, NgbAccordionDirective, NgbAccordionCollapse, NgbAccordionBody, NgbAccordionItem, NgbAccordionHeader],
imports: [CommonModule, ScrobbleEventTypePipe, NgbAccordionDirective, NgbAccordionCollapse, NgbAccordionBody, NgbAccordionItem, NgbAccordionHeader, TranslocoModule],
templateUrl: './user-holds.component.html',
styleUrls: ['./user-holds.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -1,385 +1,440 @@
<app-side-nav-companion-bar>
<h2 title>
User Dashboard
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === FragmentID.Account">
<app-change-email></app-change-email>
<app-change-password></app-change-password>
<app-change-age-restriction></app-change-age-restriction>
<app-anilist-key></app-anilist-key>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Preferences">
<p>
These are global settings that are bound to your account.
</p>
<ng-container *transloco="let t; read:'user-preferences'">
<app-side-nav-companion-bar>
<h2 title>
{{t('title')}}
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ t(tab.title) }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === FragmentID.Account">
<app-change-email></app-change-email>
<app-change-password></app-change-password>
<app-change-age-restriction></app-change-age-restriction>
<app-anilist-key></app-anilist-key>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Preferences">
<p>
{{t('pref-description')}}
</p>
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<div ngbAccordion [closeOthers]="true" #acc="ngbAccordion">
<div ngbAccordionItem [id]="AccordionPanelID.GlobalSettings" title="Global Settings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.GlobalSettings)" aria-controls="collapseOne">
Global Settings
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<div ngbAccordion [closeOthers]="true" #acc="ngbAccordion">
<div ngbAccordionItem [id]="AccordionPanelID.GlobalSettings" [collapsed]="false">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.GlobalSettings)" aria-controls="collapseOne">
{{t('global-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-global-layoutmode" class="form-label">{{t('page-layout-mode-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>{{t('page-layout-mode-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-global-layoutmode-help">
<ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="globalPageLayoutMode" id="settings-global-layoutmode">
<option *ngFor="let opt of pageLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-global-locale" class="form-label">{{t('locale-label')}}</label>
<i class="fa fa-info-circle ms-1"
aria-hidden="true" placement="right" [ngbTooltip]="localeTooltip" role="button" tabindex="0"></i>
<ng-template #localeTooltip>{{t('locale-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-global-locale-help">
<ng-container [ngTemplateOutlet]="localeTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="locale" id="settings-global-locale">
<option *ngFor="let opt of locales" [value]="opt.isoCode">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="blur-unread-summaries" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="settings-global-blurUnreadSummaries-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="blur-unread-summaries">{{t('blur-unread-summaries-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="blurUnreadSummariesTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #blurUnreadSummariesTooltip>{{t('blur-unread-summaries-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-global-blurUnreadSummaries-help">
<ng-container [ngTemplateOutlet]="blurUnreadSummariesTooltip"></ng-container>
</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="prompt-download" role="switch" formControlName="promptForDownloadSize" class="form-check-input" aria-describedby="settings-global-promptForDownloadSize-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="prompt-download">{{t('prompt-on-download-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="promptForDownloadSizeTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #promptForDownloadSizeTooltip>{{t('prompt-on-download-tooltip', {size: '100'})}}</ng-template>
<span class="visually-hidden" id="settings-global-promptForDownloadSize-help">
<ng-container [ngTemplateOutlet]="promptForDownloadSizeTooltip"></ng-container>
</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="no-transitions" role="switch" formControlName="noTransitions" class="form-check-input"
aria-describedby="settings-global-noTransitions-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="no-transitions">{{t('disable-animations-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="noTransitionsTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #noTransitionsTooltip>{{t('disable-animations-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-global-noTransitions-help">
<ng-container [ngTemplateOutlet]="noTransitionsTooltip"></ng-container>
</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="collapse-relationships" role="switch" formControlName="collapseSeriesRelationships"
aria-describedby="settings-collapse-relationships-help" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="collapse-relationships">{{t('collapse-series-relationships-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="collapseSeriesRelationshipsTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #collapseSeriesRelationshipsTooltip>{{t('collapse-series-relationships-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-collapse-relationships-help">
<ng-container [ngTemplateOutlet]="collapseSeriesRelationshipsTooltip"></ng-container>
</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="share-reviews" role="switch" formControlName="shareReviews"
aria-describedby="settings-share-reviews-help" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="share-reviews">{{t('share-series-reviews-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="shareReviewsTooltip" role="button" tabindex="0"></i>
</div>
<ng-template #shareReviewsTooltip>{{t('share-series-reviews-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-share-reviews-help">
<ng-container [ngTemplateOutlet]="shareReviewsTooltip"></ng-container>
</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</ng-template>
</div>
</div>
</div>
<div ngbAccordionItem [id]="AccordionPanelID.ImageReader">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.ImageReader)" aria-controls="collapseOne">
{{t('image-reader-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-global-layoutmode" class="form-label">Page Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Show items as cards or list view on Series Detail page</ng-template>
<span class="visually-hidden" id="settings-global-layoutmode-help">Show items as cards or list view on Series Detail page</span>
<select class="form-select" aria-describedby="manga-header" formControlName="globalPageLayoutMode" id="settings-global-layoutmode">
<option *ngFor="let opt of pageLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="blur-unread-summaries" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="settings-global-blurUnreadSummaries-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="blur-unread-summaries">Blur Unread Summaries</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="blurUnreadSummariesTooltip" role="button" tabindex="0"></i>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-reading-direction" class="form-label">{{t('reading-direction-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>{{t('reading-direction-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">
<ng-container [ngTemplateOutlet]="readingDirectionTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<ng-template #blurUnreadSummariesTooltip>Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)</ng-template>
<span class="visually-hidden" id="settings-global-blurUnreadSummaries-help">Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)</span>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-scaling-option" class="form-label">{{t('scaling-option-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="scalingOptionTooltip" role="button" tabindex="0"></i>
<ng-template #scalingOptionTooltip>{{t('scaling-option-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-scaling-option-help">
<ng-container [ngTemplateOutlet]="scalingOptionTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="prompt-download" role="switch" formControlName="promptForDownloadSize" class="form-check-input" aria-describedby="settings-global-promptForDownloadSize-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="prompt-download">Prompt on Download</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="promptForDownloadSizeTooltip" role="button" tabindex="0"></i>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-pagesplit-option" class="form-label">{{t('page-splitting-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>{{t('page-splitting-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-pagesplit-option-help">
<ng-container [ngTemplateOutlet]="pageSplitOptionTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-readingmode-option" class="form-label">{{t('reading-mode-label')}}</label>
<select class="form-select" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2" *ngIf="true">
<label for="settings-layoutmode-option" class="form-label">{{t('layout-mode-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>{{t('layout-mode-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-layoutmode-option-help">
<ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="manga-header" formControlName="layoutMode" id="settings-layoutmode-option">
<option *ngFor="let opt of layoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-background-color-option" class="form-label">{{t('background-color-label')}}</label>
<input [value]="user!.preferences!.backgroundColor"
class="form-control"
id="settings-background-color-option"
(colorPickerChange)="handleBackgroundColorChange()"
[style.background]="user!.preferences!.backgroundColor"
[cpAlphaChannel]="'disabled'"
[(colorPicker)]="user!.preferences!.backgroundColor"/>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">{{t('auto-close-menu-label')}}</label>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="show-screen-hints" role="switch" formControlName="showScreenHints" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="show-screen-hints">{{t('show-screen-hints-label')}}</label>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="emulate-book" role="switch" formControlName="emulateBook" class="form-check-input" [value]="true">
<label class="form-check-label me-1" for="emulate-book">{{t('emulate-comic-book-label')}}</label><i class="fa fa-info-circle" aria-hidden="true" placement="top" ngbTooltip="Applies a shadow effect to emulate reading from a book" role="button" tabindex="0"></i>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="swipe-to-paginate" role="switch" formControlName="swipeToPaginate" class="form-check-input" [value]="true">
<label class="form-check-label me-1" for="swipe-to-paginate">{{t('swipe-to-paginate-label')}}</label><i class="fa fa-info-circle" aria-hidden="true" placement="top" ngbTooltip="Should swiping on the screen cause the next or previous page to be triggered" role="button" tabindex="0"></i>
</div>
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</ng-template>
</div>
</div>
</div>
<div ngbAccordionItem [id]="AccordionPanelID.BookReader">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.BookReader)" aria-controls="collapseOne">
{{t('book-reader-settings-title')}}
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="taptopaginate-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="form-check-input" [value]="true" aria-labelledby="taptopaginate-label">
<label for="taptopaginate" class="form-check-label">{{t('tap-to-paginate-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-taptopaginate-option-help">
<ng-container [ngTemplateOutlet]="tapToPaginateOptionTooltip"></ng-container>
</span>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="immersivemode-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="immersivemode" formControlName="bookReaderImmersiveMode" class="form-check-input" [value]="true" aria-labelledby="immersivemode-label">
<label for="immersivemode" class="form-check-label">{{t('immersive-mode-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="immersivemodeOptionTooltip" role="button" tabindex="0"></i>
<ng-template #immersivemodeOptionTooltip>{{t('immersive-mode-label')}}</ng-template>
<span class="visually-hidden" id="settings-immersivemode-option-help">
<ng-container [ngTemplateOutlet]="immersivemodeOptionTooltip"></ng-container>
</span>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">{{t('reading-direction-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>{{t('reading-direction-book-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-book-reading-direction-book-help">
<ng-container [ngTemplateOutlet]="bookReadingDirectionTooltip"></ng-container>
</span>
<select id="settings-book-reading-direction" class="form-select" aria-describedby="settings-book-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<ng-template #promptForDownloadSizeTooltip>Prompt when a download exceeds 100MB in size</ng-template>
<span class="visually-hidden" id="settings-global-promptForDownloadSize-help">Prompt when a download exceeds 100MB in size</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="no-transitions" role="switch" formControlName="noTransitions" class="form-check-input"
aria-describedby="settings-global-noTransitions-help" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="no-transitions">Disable Animations</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="noTransitionsTooltip" role="button" tabindex="0"></i>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-fontfamily-option" class="form-label">{{t('font-family-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>{{t('font-family-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-fontfamily-option-help">
<ng-container [ngTemplateOutlet]="fontFamilyOptionTooltip"></ng-container>
</span>
<select id="settings-fontfamily-option" class="form-select" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-writing-style" class="form-label me-1">{{t('writing-style-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" aria-describedby="settings-book-writing-style-help" placement="right" [ngbTooltip]="bookWritingStyleToolTip" role="button" tabindex="0"></i>
<ng-template #bookWritingStyleToolTip>{{t('writing-style-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-book-writing-style-help">
<ng-container [ngTemplateOutlet]="bookWritingStyleToolTip"></ng-container>
</span>
<select class="form-select" aria-describedby="settings-book-writing-style-help" formControlName="bookReaderWritingStyle" id="settings-book-writing-style" >
<option *ngFor="let opt of bookWritingStyles" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<ng-template #noTransitionsTooltip>Turns off animations in the site. Useful for e-ink readers</ng-template>
<span class="visually-hidden" id="settings-global-noTransitions-help">Turns off animations in the site. Useful for e-ink readers</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="collapse-relationships" role="switch" formControlName="collapseSeriesRelationships"
aria-describedby="settings-collapse-relationships-help" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="collapse-relationships">Collapse Series Relationships</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="collapseSeriesRelationshipsTooltip" role="button" tabindex="0"></i>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-layout-mode" class="form-label">{{t('layout-mode-book-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="bookLayoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #bookLayoutModeTooltip>{{t('layout-mode-book-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-book-layout-mode-help">
<ng-container [ngTemplateOutlet]="bookLayoutModeTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="settings-book-layout-mode-help" formControlName="bookReaderLayoutMode" id="settings-book-layout-mode">
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<ng-template #collapseSeriesRelationshipsTooltip>Experimental: Should Kavita show Series that have no relationships or is the parent/prequel</ng-template>
<span class="visually-hidden" id="settings-collapse-relationships-help">Experimental: Should Kavita show Series that have no relationships or is the parent/prequel</span>
</div>
</div>
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="form-check form-switch">
<input type="checkbox" id="share-reviews" role="switch" formControlName="shareReviews"
aria-describedby="settings-share-reviews-help" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="share-reviews">Share Series Reviews</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="shareReviewsTooltip" role="button" tabindex="0"></i>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-color-theme-option" class="form-label">{{t('color-theme-book-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="bookColorThemeTooltip" role="button" tabindex="0"></i>
<ng-template #bookColorThemeTooltip>{{t('color-theme-book-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-color-theme-option-help">
<ng-container [ngTemplateOutlet]="bookColorThemeTooltip"></ng-container>
</span>
<select class="form-select" aria-describedby="settings-color-theme-option-help" formControlName="bookReaderThemeName" id="settings-color-theme-option">
<option *ngFor="let opt of bookColorThemes" [value]="opt.name">{{opt.name | titlecase}}</option>
</select>
</div>
<ng-template #shareReviewsTooltip>Should Kavita include your reviews of Series for other users</ng-template>
<span class="visually-hidden" id="settings-share-reviews-help">Should Kavita include your reviews of Series for other users</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">Save</button>
</div>
</ng-template>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label for="fontsize" class="form-label range-label">{{t('font-size-book-label')}}</label>
<input type="range" class="form-range" id="fontsize"
min="50" max="300" step="10" formControlName="bookReaderFontSize">
<span class="range-text">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label" for="linespacing">{{t('line-height-book-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>{{t('line-height-book-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-booklineheight-option-help">
<ng-container [ngTemplateOutlet]="bookLineHeightOptionTooltip"></ng-container>
</span>
</div>
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help">
<span class="range-text">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label">{{t('margin-book-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>{{t('margin-book-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-bookmargin-option-help">
<ng-container [ngTemplateOutlet]="bookReaderMarginOptionTooltip"></ng-container>
</span>
</div>
<input type="range" class="form-range" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" aria-describedby="bookmargin">
<span class="range-text">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">{{t('reset')}}</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">{{t('save')}}</button>
</div>
</ng-template>
</div>
</div>
</div>
</div>
<div ngbAccordionItem [id]="AccordionPanelID.ImageReader" title="Image Reader">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.ImageReader)" aria-controls="collapseOne">
Image Reader
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-scaling-option" class="form-label">Scaling Options</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
<span class="visually-hidden" id="settings-scaling-option-help">How to scale the image to your screen.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-pagesplit-option" class="form-label">Page Splitting</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>How to split a full width image (ie both left and right images are combined)</ng-template>
<span class="visually-hidden" id="settings-pagesplit-option-help">How to split a full width image (ie both left and right images are combined)</span>
<select class="form-select" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-readingmode-option" class="form-label">Reading Mode</label>
<select class="form-select" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2" *ngIf="true">
<label for="settings-layoutmode-option" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Render a single image to the screen to two side-by-side images</ng-template>
<span class="visually-hidden" id="settings-layoutmode-option-help"><ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="manga-header" formControlName="layoutMode" id="settings-layoutmode-option">
<option *ngFor="let opt of layoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-background-color-option" class="form-label">Background Color</label>
<input [value]="user.preferences.backgroundColor"
class="form-control"
id="settings-background-color-option"
(colorPickerChange)="handleBackgroundColorChange()"
[style.background]="user.preferences.backgroundColor"
[cpAlphaChannel]="'disabled'"
[(colorPicker)]="user.preferences.backgroundColor"/>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="show-screen-hints" role="switch" formControlName="showScreenHints" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="show-screen-hints">Show Screen Hints</label>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="emulate-book" role="switch" formControlName="emulateBook" class="form-check-input" [value]="true">
<label class="form-check-label me-1" for="emulate-book">Emulate comic book</label><i class="fa fa-info-circle" aria-hidden="true" placement="top" ngbTooltip="Applies a shadow effect to emulate reading from a book" role="button" tabindex="0"></i>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="swipe-to-paginate" role="switch" formControlName="swipeToPaginate" class="form-check-input" [value]="true">
<label class="form-check-label me-1" for="swipe-to-paginate">Swipe to Paginate</label><i class="fa fa-info-circle" aria-hidden="true" placement="top" ngbTooltip="Should swiping on the screen cause the next or previous page to be triggered" role="button" tabindex="0"></i>
</div>
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">Save</button>
</div>
</ng-template>
</div>
</div>
</div>
<div ngbAccordionItem [id]="AccordionPanelID.BookReader" title="Book Reader">
<h2 class="accordion-header" ngbAccordionHeader>
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.BookReader)" aria-controls="collapseOne">
Book Reader
</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="taptopaginate-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="form-check-input" [value]="true" aria-labelledby="taptopaginate-label">
<label for="taptopaginate" class="form-check-label">Tap to Paginate</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>Should the sides of the book reader screen allow tapping on it to move to prev/next page</ng-template>
<span class="visually-hidden" id="settings-taptopaginate-option-help">Should the sides of the book reader screen allow tapping on it to move to prev/next page</span>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="immersivemode-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="immersivemode" formControlName="bookReaderImmersiveMode" class="form-check-input" [value]="true" aria-labelledby="immersivemode-label">
<label for="immersivemode" class="form-check-label">Immersive Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="immersivemodeOptionTooltip" role="button" tabindex="0"></i>
<ng-template #immersivemodeOptionTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
<span class="visually-hidden" id="settings-immersivemode-option-help">This will hide the menu behind a click on the reader document and turn tap to paginate on</span>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-book-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select id="settings-book-reading-direction" class="form-select" aria-describedby="settings-book-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</form>
</ng-container>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-fontfamily-option" class="form-label">Font Family</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>Font family to load up. Default will load the book's default font</ng-template>
<span class="visually-hidden" id="settings-fontfamily-option-help">Font family to load up. Default will load the book's default font</span>
<select id="settings-fontfamily-option" class="form-select" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-writing-style" class="form-label me-1">Writing Style</label><i class="fa fa-info-circle" aria-hidden="true" aria-describedby="settings-book-writing-style-help" placement="right" [ngbTooltip]="bookWritingStyleToolTip" role="button" tabindex="0"></i>
<ng-template #bookWritingStyleToolTip>Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.</ng-template>
<span class="visually-hidden" id="settings-book-writing-style-help"><ng-container [ngTemplateOutlet]="bookWritingStyleToolTip"></ng-container></span>
<select class="form-select" aria-describedby="settings-book-writing-style-help" formControlName="bookReaderWritingStyle" id="settings-book-writing-style" >
<option *ngFor="let opt of bookWritingStyles" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-layout-mode" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLayoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #bookLayoutModeTooltip>How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page</ng-template>
<span class="visually-hidden" id="settings-book-layout-mode-help"><ng-container [ngTemplateOutlet]="bookLayoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-book-layout-mode-help" formControlName="bookReaderLayoutMode" id="settings-book-layout-mode">
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-color-theme-option" class="form-label">Color Theme</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookColorThemeTooltip" role="button" tabindex="0"></i>
<ng-template #bookColorThemeTooltip>What color theme to apply to the book reader content and menuing</ng-template>
<span class="visually-hidden" id="settings-color-theme-option-help"><ng-container [ngTemplateOutlet]="bookColorThemeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-color-theme-option-help" formControlName="bookReaderThemeName" id="settings-color-theme-option">
<option *ngFor="let opt of bookColorThemes" [value]="opt.name">{{opt.name | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label for="fontsize" class="form-label range-label">Font Size</label>
<input type="range" class="form-range" id="fontsize"
min="50" max="300" step="10" formControlName="bookReaderFontSize">
<span class="range-text">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label" for="linespacing">Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="visually-hidden" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
</div>
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help">
<span class="range-text">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label">Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="visually-hidden" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
</div>
<input type="range" class="form-range" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" aria-describedby="bookmargin">
<span class="range-text">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">Save</button>
</div>
</ng-template>
</div>
</div>
</div>
</div>
</form>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Clients">
<div class="alert alert-warning" role="alert" *ngIf="!opdsEnabled">OPDS is not enabled on this server. This will not affect Tachiyomi users.</div>
<p>All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.</p>
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Theme">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats></app-user-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
<app-user-scrobble-history></app-user-scrobble-history>
<app-user-holds></app-user-holds>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<ng-container *ngIf="tab.fragment === FragmentID.Clients">
<div class="alert alert-warning" role="alert" *ngIf="!opdsEnabled">{{t('clients-opds-alert')}}</div>
<p>{{t('clients-opds-description')}}</p>
<app-api-key [tooltipText]="t('clients-api-key-tooltip')"></app-api-key>
<app-api-key [title]="t('clients-opds-url-tooltip')" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Theme">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats></app-user-stats>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Scrobbling">
<app-user-scrobble-history></app-user-scrobble-history>
<app-user-holds></app-user-holds>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
</ng-container>

View file

@ -27,7 +27,7 @@ import { AccountService } from 'src/app/_services/account.service';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { SettingsService } from 'src/app/admin/settings.service';
import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode';
import { forkJoin } from 'rxjs';
import {forkJoin} from 'rxjs';
import { bookColorThemes } from 'src/app/book-reader/_components/reader-settings/reader-settings.component';
import { BookService } from 'src/app/book-reader/_services/book.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -46,6 +46,9 @@ import { ChangeEmailComponent } from '../change-email/change-email.component';
import { NgFor, NgIf, NgTemplateOutlet, TitleCasePipe } from '@angular/common';
import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {LocalizationService} from "../../_services/localization.service";
import {Language} from "../../_models/metadata/language";
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -70,7 +73,10 @@ enum FragmentID {
styleUrls: ['./user-preferences.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent, ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent, ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe]
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, TranslocoModule]
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -91,18 +97,20 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
fontFamilies: Array<string> = [];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Account', fragment: FragmentID.Account},
{title: 'Preferences', fragment: FragmentID.Preferences},
{title: '3rd Party Clients', fragment: FragmentID.Clients},
{title: 'Theme', fragment: FragmentID.Theme},
{title: 'Devices', fragment: FragmentID.Devices},
{title: 'Stats', fragment: FragmentID.Stats},
{title: 'account-tab', fragment: FragmentID.Account},
{title: 'preferences-tab', fragment: FragmentID.Preferences},
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
{title: 'theme-tab', fragment: FragmentID.Theme},
{title: 'devices-tab', fragment: FragmentID.Devices},
{title: 'stats-tab', fragment: FragmentID.Stats},
];
locales: Array<Language> = [{title: 'English', isoCode: 'en'}];
active = this.tabs[1];
opdsEnabled: boolean = false;
opdsUrl: string = '';
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
private readonly destroyRef = inject(DestroyRef);
private readonly trasnlocoService = inject(TranslocoService);
get AccordionPanelID() {
return AccordionPanelID;
@ -115,7 +123,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
private router: Router, private readonly cdRef: ChangeDetectorRef) {
private router: Router, private readonly cdRef: ChangeDetectorRef, public localizationService: LocalizationService) {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck();
@ -124,9 +132,14 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
});
this.localizationService.getLocales().subscribe(res => {
this.locales = res;
this.cdRef.markForCheck();
});
this.accountService.hasValidLicense().subscribe(res => {
if (res) {
this.tabs.push({title: 'Scrobbling', fragment: FragmentID.Scrobbling});
this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling});
this.cdRef.markForCheck();
}
@ -195,6 +208,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.addControl('noTransitions', new FormControl(this.user.preferences.noTransitions, []));
this.settingsForm.addControl('collapseSeriesRelationships', new FormControl(this.user.preferences.collapseSeriesRelationships, []));
this.settingsForm.addControl('shareReviews', new FormControl(this.user.preferences.shareReviews, []));
this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale, []));
if (this.locales.length === 1) {
this.settingsForm.get('locale')?.disable();
}
this.cdRef.markForCheck();
});
@ -241,6 +259,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate);
this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships);
this.settingsForm.get('shareReviews')?.setValue(this.user.preferences.shareReviews);
this.settingsForm.get('locale')?.setValue(this.user.preferences.locale);
this.cdRef.markForCheck();
this.settingsForm.markAsPristine();
}
@ -275,11 +294,12 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
emulateBook: modelSettings.emulateBook,
swipeToPaginate: modelSettings.swipeToPaginate,
collapseSeriesRelationships: modelSettings.collapseSeriesRelationships,
shareReviews: modelSettings.shareReviews
shareReviews: modelSettings.shareReviews,
locale: modelSettings.locale
};
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
this.toastr.success('User preferences updated');
this.toastr.success(this.trasnlocoService.translate('user-preferences.success-toast'));
if (this.user) {
this.user.preferences = updatedPrefs;
this.cdRef.markForCheck();
@ -294,6 +314,4 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.markAsTouched();
this.cdRef.markForCheck();
}
protected readonly undefined = undefined;
}

View file

@ -5,7 +5,6 @@ import {
NgbAccordionBody,
NgbAccordionButton, NgbAccordionCollapse,
NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
NgbAccordionModule, NgbAccordionToggle,
NgbCollapseModule,
NgbNavModule,
NgbTooltipModule