mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add i18n translations for book components, services, and readers (en/es) (#2738)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
import {OAuthService} from 'angular-oauth2-oidc';
|
||||
import {AuthService} from '../../../shared/service/auth.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-oidc-callback',
|
||||
@@ -13,6 +13,7 @@ export class OidcCallbackComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private oauthService = inject(OAuthService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
@@ -26,8 +27,8 @@ export class OidcCallbackComponent implements OnInit {
|
||||
console.error('[OIDC Callback] Login failed', e);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'OIDC Login Failed',
|
||||
detail: 'Redirecting to local login...',
|
||||
summary: this.t.translate('auth.oidc.loginFailedSummary'),
|
||||
detail: this.t.translate('auth.oidc.redirectingDetail'),
|
||||
life: 3000
|
||||
});
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.addPhysicalBook'">
|
||||
<div class="add-physical-book">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-book header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Add Physical Book</h2>
|
||||
<p class="panel-description">Catalog a physical book without a digital file</p>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">{{ t('description') }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -14,7 +15,7 @@
|
||||
[text]="true"
|
||||
[rounded]="true"
|
||||
class="close-button"
|
||||
pTooltip="Close"
|
||||
[pTooltip]="t('closeTooltip')"
|
||||
tooltipPosition="left"/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label for="library" class="form-label">
|
||||
<i class="pi pi-folder label-icon"></i>
|
||||
Library
|
||||
{{ t('libraryLabel') }}
|
||||
<span class="required-indicator">*</span>
|
||||
</label>
|
||||
<p-select
|
||||
@@ -31,7 +32,7 @@
|
||||
[(ngModel)]="selectedLibraryId"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="Select a library"
|
||||
[placeholder]="t('libraryPlaceholder')"
|
||||
[style]="{'width': '100%'}"/>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +42,7 @@
|
||||
<div class="form-group highlight-group flex-2">
|
||||
<label for="title" class="form-label">
|
||||
<i class="pi pi-tag label-icon"></i>
|
||||
Title
|
||||
{{ t('titleLabel') }}
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
@@ -50,7 +51,7 @@
|
||||
pInputText
|
||||
[(ngModel)]="title"
|
||||
class="input-full"
|
||||
placeholder="e.g., The Great Gatsby"
|
||||
[placeholder]="t('titlePlaceholder')"
|
||||
[class.filled]="title.trim()"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +59,7 @@
|
||||
<div class="form-group highlight-group flex-1">
|
||||
<label for="isbn" class="form-label">
|
||||
<i class="pi pi-barcode label-icon"></i>
|
||||
ISBN
|
||||
{{ t('isbnLabel') }}
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
@@ -67,7 +68,7 @@
|
||||
pInputText
|
||||
[(ngModel)]="isbn"
|
||||
class="input-full"
|
||||
placeholder="e.g., 9780134685991"/>
|
||||
[placeholder]="t('isbnPlaceholder')"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +78,7 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label for="authors" class="form-label">
|
||||
<i class="pi pi-user label-icon"></i>
|
||||
Authors
|
||||
{{ t('authorsLabel') }}
|
||||
</label>
|
||||
<div class="autocomplete-wrapper">
|
||||
<p-autoComplete
|
||||
@@ -89,7 +90,7 @@
|
||||
[suggestions]="filteredAuthors"
|
||||
[forceSelection]="false"
|
||||
[showClear]="false"
|
||||
placeholder="Type author name and press Enter"
|
||||
[placeholder]="t('authorsPlaceholder')"
|
||||
(completeMethod)="filterAuthors($event)"
|
||||
(onKeyUp)="onAutoCompleteKeyUp('authors', $event)"
|
||||
(onSelect)="onAutoCompleteSelect('authors', $event)">
|
||||
@@ -102,14 +103,14 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label for="description" class="form-label">
|
||||
<i class="pi pi-align-left label-icon"></i>
|
||||
Description
|
||||
{{ t('descriptionLabel') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
pTextarea
|
||||
[(ngModel)]="description"
|
||||
rows="3"
|
||||
placeholder="Brief description of the book..."
|
||||
[placeholder]="t('descriptionPlaceholder')"
|
||||
class="input-full"></textarea>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +120,7 @@
|
||||
<div class="form-group highlight-group flex-1">
|
||||
<label for="publisher" class="form-label">
|
||||
<i class="pi pi-building label-icon"></i>
|
||||
Publisher
|
||||
{{ t('publisherLabel') }}
|
||||
</label>
|
||||
<input
|
||||
id="publisher"
|
||||
@@ -127,13 +128,13 @@
|
||||
pInputText
|
||||
[(ngModel)]="publisher"
|
||||
class="input-full"
|
||||
placeholder="Publisher name"/>
|
||||
[placeholder]="t('publisherPlaceholder')"/>
|
||||
</div>
|
||||
|
||||
<div class="form-group highlight-group flex-1">
|
||||
<label for="publishedDate" class="form-label">
|
||||
<i class="pi pi-calendar label-icon"></i>
|
||||
Published Date
|
||||
{{ t('publishedDateLabel') }}
|
||||
</label>
|
||||
<input
|
||||
id="publishedDate"
|
||||
@@ -141,7 +142,7 @@
|
||||
pInputText
|
||||
[(ngModel)]="publishedDate"
|
||||
class="input-full"
|
||||
placeholder="e.g., 2020 or 2020-05-15"/>
|
||||
[placeholder]="t('publishedDatePlaceholder')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +150,7 @@
|
||||
<div class="form-group highlight-group flex-1">
|
||||
<label for="language" class="form-label">
|
||||
<i class="pi pi-globe label-icon"></i>
|
||||
Language
|
||||
{{ t('languageLabel') }}
|
||||
</label>
|
||||
<input
|
||||
id="language"
|
||||
@@ -157,19 +158,19 @@
|
||||
pInputText
|
||||
[(ngModel)]="language"
|
||||
class="input-full"
|
||||
placeholder="e.g., English"/>
|
||||
[placeholder]="t('languagePlaceholder')"/>
|
||||
</div>
|
||||
|
||||
<div class="form-group highlight-group flex-1">
|
||||
<label for="pageCount" class="form-label">
|
||||
<i class="pi pi-file label-icon"></i>
|
||||
Page Count
|
||||
{{ t('pageCountLabel') }}
|
||||
</label>
|
||||
<p-inputnumber
|
||||
id="pageCount"
|
||||
[(ngModel)]="pageCount"
|
||||
[useGrouping]="false"
|
||||
placeholder="Number of pages"
|
||||
[placeholder]="t('pageCountPlaceholder')"
|
||||
class="input-full">
|
||||
</p-inputnumber>
|
||||
</div>
|
||||
@@ -180,7 +181,7 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label for="categories" class="form-label">
|
||||
<i class="pi pi-tags label-icon"></i>
|
||||
Categories/Genres
|
||||
{{ t('categoriesLabel') }}
|
||||
</label>
|
||||
<div class="autocomplete-wrapper">
|
||||
<p-autoComplete
|
||||
@@ -192,7 +193,7 @@
|
||||
[suggestions]="filteredCategories"
|
||||
[forceSelection]="false"
|
||||
[showClear]="true"
|
||||
placeholder="Type category and press Enter"
|
||||
[placeholder]="t('categoriesPlaceholder')"
|
||||
(completeMethod)="filterCategories($event)"
|
||||
(onKeyUp)="onAutoCompleteKeyUp('categories', $event)"
|
||||
(onSelect)="onAutoCompleteSelect('categories', $event)">
|
||||
@@ -206,28 +207,28 @@
|
||||
@if (!selectedLibraryId) {
|
||||
<div class="validation-message error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>Library is required</span>
|
||||
<span>{{ t('validationLibraryRequired') }}</span>
|
||||
</div>
|
||||
} @else if (!title.trim() && !isbn.trim()) {
|
||||
<div class="validation-message error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>Title or ISBN is required</span>
|
||||
<span>{{ t('validationTitleOrIsbn') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="validation-message success">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span>Ready to create</span>
|
||||
<span>{{ t('validationReady') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
[label]="t('cancelButton')"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
(onClick)="cancel()"/>
|
||||
<p-button
|
||||
label="Add Physical Book"
|
||||
[label]="t('addButton')"
|
||||
icon="pi pi-plus"
|
||||
severity="success"
|
||||
(onClick)="createBook()"
|
||||
@@ -236,3 +237,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {Library} from '../../model/library.model';
|
||||
import {CreatePhysicalBookRequest} from '../../model/book.model';
|
||||
import {filter, take} from 'rxjs/operators';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-physical-book-dialog',
|
||||
@@ -27,7 +28,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
Select,
|
||||
Textarea,
|
||||
InputNumber,
|
||||
AutoComplete
|
||||
AutoComplete,
|
||||
TranslocoDirective
|
||||
],
|
||||
styleUrl: './add-physical-book-dialog.component.scss',
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.fileUploader'">
|
||||
<div class="additional-file-uploader-container">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-file-plus header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Upload Additional File</h2>
|
||||
<p class="panel-description">{{ book.metadata?.title || 'Unknown Title' }}</p>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">{{ book.metadata?.title || t('unknownTitle') }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -24,18 +25,18 @@
|
||||
[(ngModel)]="fileType"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select file type"
|
||||
[placeholder]="t('selectFileType')"
|
||||
class="full-width"
|
||||
[disabled]="isUploading"
|
||||
></p-select>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div class="description-field">
|
||||
<label for="description" class="description-label">Description (Optional)</label>
|
||||
<label for="description" class="description-label">{{ t('descriptionLabel') }}</label>
|
||||
<p-textarea
|
||||
[(ngModel)]="description"
|
||||
rows="3"
|
||||
placeholder="Add a description for this file..."
|
||||
[attr.placeholder]="t('descriptionPlaceholder')"
|
||||
class="full-width"
|
||||
[disabled]="isUploading"
|
||||
></p-textarea>
|
||||
@@ -98,7 +99,7 @@
|
||||
<i
|
||||
class="pi pi-spin pi-spinner status-icon"
|
||||
style="color: slateblue"
|
||||
pTooltip="Uploading"
|
||||
[pTooltip]="t('statusUploading')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
@@ -106,7 +107,7 @@
|
||||
<i
|
||||
class="pi pi-check status-icon"
|
||||
style="color: green"
|
||||
pTooltip="Uploaded"
|
||||
[pTooltip]="t('statusUploaded')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
@@ -114,7 +115,7 @@
|
||||
<i
|
||||
class="pi pi-exclamation-triangle status-icon"
|
||||
style="color: darkred"
|
||||
pTooltip="{{ uploadFile.errorMessage || 'Upload failed' }}"
|
||||
[pTooltip]="uploadFile.errorMessage || t('statusUploadFailed')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
}
|
||||
@@ -131,12 +132,11 @@
|
||||
<ng-template #empty>
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-cloud-upload empty-icon"></i>
|
||||
<p class="empty-text">Drag and drop a file here to upload.</p>
|
||||
<p class="empty-subtext">
|
||||
Upload an additional file for <strong>{{ book.metadata?.title || 'this book' }}</strong>.
|
||||
</p>
|
||||
<p class="empty-text">{{ t('dragDropText') }}</p>
|
||||
<p class="empty-subtext" [innerHTML]="t('uploadForBook', { title: book.metadata?.title || t('thisBook') })"></p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-fileupload>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ChangeDetectorRef, inject } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
|
||||
@@ -13,6 +13,7 @@ import { AppSettingsService } from '../../../../shared/service/app-settings.serv
|
||||
import { Book, AdditionalFileType } from '../../model/book.model';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
import { TranslocoDirective, TranslocoService } from '@jsverse/transloco';
|
||||
|
||||
interface FileTypeOption {
|
||||
label: string;
|
||||
@@ -34,12 +35,15 @@ interface UploadingFile {
|
||||
Button,
|
||||
FileUpload,
|
||||
Badge,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './additional-file-uploader.component.html',
|
||||
styleUrls: ['./additional-file-uploader.component.scss']
|
||||
})
|
||||
export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
book!: Book;
|
||||
files: UploadingFile[] = [];
|
||||
fileType: AdditionalFileType = AdditionalFileType.ALTERNATIVE_FORMAT;
|
||||
@@ -47,10 +51,7 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
isUploading = false;
|
||||
maxFileSizeBytes?: number;
|
||||
|
||||
fileTypeOptions: FileTypeOption[] = [
|
||||
{ label: 'Alternative Format', value: AdditionalFileType.ALTERNATIVE_FORMAT },
|
||||
{ label: 'Supplementary File', value: AdditionalFileType.SUPPLEMENTARY }
|
||||
];
|
||||
fileTypeOptions: FileTypeOption[] = [];
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -65,6 +66,10 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.book = this.config.data.book;
|
||||
this.fileTypeOptions = [
|
||||
{ label: this.t.translate('book.fileUploader.typeAlternativeFormat'), value: AdditionalFileType.ALTERNATIVE_FORMAT },
|
||||
{ label: this.t.translate('book.fileUploader.typeSupplementary'), value: AdditionalFileType.SUPPLEMENTARY }
|
||||
];
|
||||
this.appSettingsService.appSettings$
|
||||
.pipe(
|
||||
filter(settings => settings != null),
|
||||
@@ -106,7 +111,8 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
const file = newFiles[0];
|
||||
|
||||
if (this.maxFileSizeBytes && file.size > this.maxFileSizeBytes) {
|
||||
const errorMsg = `File exceeds maximum size of ${this.formatSize(this.maxFileSizeBytes)}`;
|
||||
const maxSize = this.formatSize(this.maxFileSizeBytes);
|
||||
const errorMsg = this.t.translate('book.fileUploader.toast.fileTooLargeError', { maxSize });
|
||||
this.files = [{
|
||||
file,
|
||||
status: 'Failed',
|
||||
@@ -114,8 +120,8 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
}];
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'File Too Large',
|
||||
detail: `${file.name} exceeds the maximum file size of ${this.formatSize(this.maxFileSizeBytes)}`,
|
||||
summary: this.t.translate('book.fileUploader.toast.fileTooLargeSummary'),
|
||||
detail: this.t.translate('book.fileUploader.toast.fileTooLargeDetail', { fileName: file.name, maxSize }),
|
||||
life: 5000
|
||||
});
|
||||
} else {
|
||||
@@ -161,7 +167,7 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
error: (err) => {
|
||||
uploadFile.status = 'Failed';
|
||||
uploadFile.errorMessage = err?.error?.message || 'Upload failed due to unknown error.';
|
||||
uploadFile.errorMessage = err?.error?.message || this.t.translate('book.fileUploader.toast.uploadFailedUnknown');
|
||||
console.error('Upload failed for', uploadFile.file.name, err);
|
||||
if (--pending === 0) {
|
||||
this.isUploading = false;
|
||||
@@ -203,18 +209,18 @@ export class AdditionalFileUploaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getFileStatusLabel(uploadFile: UploadingFile): string {
|
||||
if (uploadFile.status === 'Failed' && uploadFile.errorMessage?.includes('exceeds maximum size')) {
|
||||
return 'Too Large';
|
||||
if (uploadFile.status === 'Failed' && uploadFile.errorMessage?.includes('maximum size')) {
|
||||
return this.t.translate('book.fileUploader.statusTooLarge');
|
||||
}
|
||||
switch (uploadFile.status) {
|
||||
case 'Pending':
|
||||
return 'Ready';
|
||||
return this.t.translate('book.fileUploader.statusReady');
|
||||
case 'Uploading':
|
||||
return 'Uploading';
|
||||
return this.t.translate('book.fileUploader.statusUploading');
|
||||
case 'Uploaded':
|
||||
return 'Uploaded';
|
||||
return this.t.translate('book.fileUploader.statusUploaded');
|
||||
case 'Failed':
|
||||
return 'Failed';
|
||||
return this.t.translate('book.fileUploader.statusFailed');
|
||||
default:
|
||||
return uploadFile.status;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.browser'">
|
||||
<div class="book-browser-container">
|
||||
<div class="book-browser-main">
|
||||
|
||||
@@ -12,17 +13,17 @@
|
||||
} @else if (isFilterActive || hasSearchTerm) {
|
||||
{{ computedFilterLabel }}
|
||||
} @else {
|
||||
All Books
|
||||
{{ t('labels.allBooks') }}
|
||||
}
|
||||
} @else if (entityType === EntityType.UNSHELVED) {
|
||||
{{ (isFilterActive || hasSearchTerm) ? 'Unshelved Books (Filtered)' : 'Unshelved Books' }}
|
||||
{{ (isFilterActive || hasSearchTerm) ? t('labels.unshelvedBooksFiltered') : t('labels.unshelvedBooks') }}
|
||||
} @else {
|
||||
{{ entityType }}: {{ (entity$ | async)?.name }}{{ (isFilterActive || hasSearchTerm) ? ' (Filtered)' : '' }}
|
||||
{{ entityType }}: {{ (entity$ | async)?.name }}{{ (isFilterActive || hasSearchTerm) ? ' ' + t('labels.filteredSuffix') : '' }}
|
||||
}
|
||||
</p>
|
||||
@if (seriesCollapseFilter.isSeriesCollapsed && (bookState$ | async)?.books; as books) {
|
||||
<p class="series-collapsed-info">
|
||||
Showing {{ books.length }} {{ books.length === 1 ? 'item' : 'items' }} (series collapsed)
|
||||
{{ t('labels.seriesCollapsedInfo', { count: books.length, itemWord: books.length === 1 ? t('labels.item') : t('labels.items') }) }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -50,7 +51,7 @@
|
||||
<a class="topbar-items topbar-item" (click)="clearFilter()">
|
||||
<i
|
||||
class="pi pi-filter-slash filter-clear-icon"
|
||||
pTooltip="Clear applied filters"
|
||||
[pTooltip]="t('tooltip.clearFilters')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
</a>
|
||||
@@ -61,7 +62,7 @@
|
||||
<a class="topbar-items topbar-item" (click)="columnPopover.toggle($event)">
|
||||
<i
|
||||
class="pi pi-eye"
|
||||
pTooltip="Visible columns"
|
||||
[pTooltip]="t('tooltip.visibleColumns')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
</a>
|
||||
@@ -73,16 +74,16 @@
|
||||
[(ngModel)]="visibleColumns"
|
||||
(ngModelChange)="onVisibleColumnsChange($event)"
|
||||
display="chip"
|
||||
placeholder="Select Columns"
|
||||
[placeholder]="t('placeholder.selectColumns')"
|
||||
[style]="{ width: '100%' }"
|
||||
[filter]="true"
|
||||
[showClear]="true"
|
||||
></p-multiSelect>
|
||||
</div>
|
||||
<div class="column-popover-footer">
|
||||
<p>Set as default?</p>
|
||||
<p>{{ t('labels.setAsDefault') }}</p>
|
||||
<p-button
|
||||
label="Save"
|
||||
[label]="t('labels.save')"
|
||||
outlined
|
||||
size="small"
|
||||
(click)="columnPreferenceService.saveVisibleColumns(visibleColumns)"></p-button>
|
||||
@@ -94,7 +95,7 @@
|
||||
<a
|
||||
class="topbar-items topbar-item"
|
||||
(click)="seriesCollapseOverlay.toggle($event)"
|
||||
pTooltip="Display settings"
|
||||
[pTooltip]="t('tooltip.displaySettings')"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-cog"></i>
|
||||
</a>
|
||||
@@ -108,12 +109,12 @@
|
||||
[ngModel]="seriesCollapseFilter.isSeriesCollapsed"
|
||||
(onChange)="onSeriesCollapseCheckboxChange($event.checked)">
|
||||
</p-checkbox>
|
||||
<label for="collapse-series-checkbox" class="display-settings-label">Collapse series</label>
|
||||
<label for="collapse-series-checkbox" class="display-settings-label">{{ t('labels.collapseSeries') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
@if (isMobile) {
|
||||
<div class="display-settings-section">
|
||||
<label class="display-settings-label">Grid columns</label>
|
||||
<label class="display-settings-label">{{ t('labels.gridColumns') }}</label>
|
||||
<div class="column-options">
|
||||
<button
|
||||
type="button"
|
||||
@@ -134,7 +135,7 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div class="display-settings-section">
|
||||
<label class="display-settings-label">Grid item size</label>
|
||||
<label class="display-settings-label">{{ t('labels.gridItemSize') }}</label>
|
||||
<p-slider
|
||||
[(ngModel)]="coverScalePreferenceService.scaleFactor"
|
||||
[min]="0.5"
|
||||
@@ -156,7 +157,7 @@
|
||||
[ngModel]="bookCardOverlayPreferenceService.showBookTypePill"
|
||||
(onChange)="bookCardOverlayPreferenceService.setShowBookTypePill($event.checked)">
|
||||
</p-checkbox>
|
||||
<label for="show-book-type-pill-checkbox" class="display-settings-label">Book type overlay</label>
|
||||
<label for="show-book-type-pill-checkbox" class="display-settings-label">{{ t('labels.bookTypeOverlay') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +168,7 @@
|
||||
<a class="topbar-items topbar-item" (click)="sortPopover.toggle($event)">
|
||||
<i
|
||||
class="pi pi-sort"
|
||||
pTooltip="Select sorting"
|
||||
[pTooltip]="t('tooltip.selectSorting')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
@if (sortCriteriaCount > 1) {
|
||||
@@ -188,7 +189,7 @@
|
||||
<a class="topbar-items topbar-item" (click)="toggleTableGrid()">
|
||||
<i
|
||||
[ngClass]="viewIcon"
|
||||
pTooltip="Toggle between Grid and Table view"
|
||||
[pTooltip]="t('tooltip.toggleView')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
</a>
|
||||
@@ -200,7 +201,7 @@
|
||||
type="button"
|
||||
class="topbar-items topbar-item"
|
||||
(click)="searchDropdown.toggle($event)"
|
||||
pTooltip="Search"
|
||||
[pTooltip]="t('tooltip.search')"
|
||||
tooltipPosition="top"
|
||||
>
|
||||
<i class="pi pi-search"></i>
|
||||
@@ -211,7 +212,7 @@
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
placeholder="Title, Author, Series, Genre, or ISBN..."
|
||||
[placeholder]="t('placeholder.search')"
|
||||
[(ngModel)]="bookTitle"
|
||||
(ngModelChange)="onSearchTermChange($event)"
|
||||
class="search-input-full"
|
||||
@@ -233,7 +234,7 @@
|
||||
<input
|
||||
type="text"
|
||||
pInputText
|
||||
placeholder="Title, Author, Series, Genre, or ISBN..."
|
||||
[placeholder]="t('placeholder.search')"
|
||||
[(ngModel)]="bookTitle"
|
||||
(ngModelChange)="onSearchTermChange($event)"
|
||||
class="search-input"
|
||||
@@ -253,7 +254,7 @@
|
||||
<a class="topbar-items topbar-item" (click)="toggleSidebar()">
|
||||
<i
|
||||
[ngClass]="showFilter ? 'pi pi-angle-double-right' : 'pi pi-angle-double-left'"
|
||||
pTooltip="Toggle sidebar filters"
|
||||
[pTooltip]="t('tooltip.toggleSidebar')"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
</a>
|
||||
@@ -271,14 +272,14 @@
|
||||
@if (bookState?.error) {
|
||||
<div class="no-books-container">
|
||||
<p class="no-books-text">
|
||||
{{ entityType === EntityType.LIBRARY ? "Failed to load library's books!" : "Failed to load shelf's books!" }}
|
||||
{{ entityType === EntityType.LIBRARY ? t('labels.failedLibrary') : t('labels.failedShelf') }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (!bookState?.error && bookState?.loaded && bookState?.books?.length === 0) {
|
||||
<div class="no-books-container">
|
||||
<p class="no-books-text">
|
||||
This collection has no books!
|
||||
{{ t('labels.noBooks') }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@@ -326,7 +327,7 @@
|
||||
<div class="selected-count-badge">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span class="selected-count">{{ selectedCount }}</span>
|
||||
<span class="selected-label">selected</span>
|
||||
<span class="selected-label">{{ t('labels.selected') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-center">
|
||||
@@ -336,7 +337,7 @@
|
||||
<p-tieredMenu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body" styleClass="footer-menu"/>
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="Metadata actions"
|
||||
[pTooltip]="t('tooltip.metadataActions')"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
@@ -348,7 +349,7 @@
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(onClick)="openShelfAssigner()"
|
||||
pTooltip="Assign to shelf"
|
||||
[pTooltip]="t('tooltip.assignToShelf')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
@if (entityType === EntityType.SHELF) {
|
||||
@@ -357,7 +358,7 @@
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(click)="unshelfBooks()"
|
||||
pTooltip="Remove from this shelf"
|
||||
[pTooltip]="t('tooltip.removeFromShelf')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -367,7 +368,7 @@
|
||||
icon="pi pi-lock"
|
||||
severity="info"
|
||||
(click)="lockUnlockMetadata()"
|
||||
pTooltip="Lock/Unlock metadata"
|
||||
[pTooltip]="t('tooltip.lockUnlockMetadata')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -377,7 +378,7 @@
|
||||
icon="pi pi-arrows-h"
|
||||
severity="info"
|
||||
(click)="moveFiles()"
|
||||
pTooltip="Organize Files"
|
||||
[pTooltip]="t('tooltip.organizeFiles')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -388,7 +389,7 @@
|
||||
severity="info"
|
||||
(click)="attachFilesToBook()"
|
||||
[disabled]="!canAttachFiles()"
|
||||
pTooltip="Attach to Another Book"
|
||||
[pTooltip]="t('tooltip.attachToBook')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -396,7 +397,7 @@
|
||||
<div class="more-actions-wrapper">
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="More actions"
|
||||
[pTooltip]="t('tooltip.moreActions')"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
@@ -414,7 +415,7 @@
|
||||
icon="pi pi-check-square"
|
||||
severity="success"
|
||||
(click)="selectAllBooks()"
|
||||
pTooltip="Select all books"
|
||||
[pTooltip]="t('tooltip.selectAll')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
<p-button
|
||||
@@ -422,7 +423,7 @@
|
||||
icon="pi pi-times"
|
||||
severity="warn"
|
||||
(click)="deselectAllBooks()"
|
||||
pTooltip="Deselect all books"
|
||||
[pTooltip]="t('tooltip.deselectAll')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
</div>
|
||||
@@ -433,7 +434,7 @@
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
(click)="confirmDeleteBooks()"
|
||||
pTooltip="Delete selected books"
|
||||
[pTooltip]="t('tooltip.deleteSelected')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -465,3 +466,4 @@
|
||||
}
|
||||
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -57,6 +57,7 @@ import {BookBrowserScrollService} from './book-browser-scroll.service';
|
||||
import {AppSettingsService} from '../../../../shared/service/app-settings.service';
|
||||
import {MultiSortPopoverComponent} from './sorting/multi-sort-popover/multi-sort-popover.component';
|
||||
import {SortService} from '../../service/sort.service';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -74,7 +75,7 @@ export enum EntityType {
|
||||
imports: [
|
||||
Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule,
|
||||
BookTableComponent, BookFilterComponent, Tooltip, NgClass, NgStyle, Popover,
|
||||
Checkbox, Slider, Divider, MultiSelect, TieredMenu, BadgeModule, MultiSortPopoverComponent
|
||||
Checkbox, Slider, Divider, MultiSelect, TieredMenu, BadgeModule, MultiSortPopoverComponent, TranslocoDirective
|
||||
],
|
||||
providers: [SeriesCollapseFilter],
|
||||
animations: [
|
||||
@@ -119,6 +120,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private filterOrchestrationService = inject(BookFilterOrchestrationService);
|
||||
private localStorageService = inject(LocalStorageService);
|
||||
private scrollService = inject(BookBrowserScrollService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
bookState$: Observable<BookState> | undefined;
|
||||
entity$: Observable<Library | Shelf | MagicShelf | null> | undefined;
|
||||
@@ -236,7 +238,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const filters = this.selectedFilter.value;
|
||||
|
||||
if (!filters || Object.keys(filters).length === 0) {
|
||||
return 'All Books';
|
||||
return this.t.translate('book.browser.labels.allBooks');
|
||||
}
|
||||
|
||||
const filterEntries = Object.entries(filters);
|
||||
@@ -258,7 +260,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
.join(', ');
|
||||
|
||||
return filterSummary.length > 50
|
||||
? `${filterEntries.length} Active Filters`
|
||||
? this.t.translate('book.browser.labels.activeFilters', {count: filterEntries.length})
|
||||
: filterSummary;
|
||||
}
|
||||
|
||||
@@ -332,7 +334,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.entityType$ = of(entityType);
|
||||
this.entity$ = of(null);
|
||||
this.seriesCollapseFilter.setContext(null, null);
|
||||
this.pageTitle.setPageTitle(currentPath === 'all-books' ? 'All Books' : 'Unshelved Books');
|
||||
this.pageTitle.setPageTitle(currentPath === 'all-books' ? this.t.translate('book.browser.labels.allBooks') : this.t.translate('book.browser.labels.unshelvedBooks'));
|
||||
} else {
|
||||
const routeEntityInfo$ = this.entityService.getEntityInfoFromRoute(this.activatedRoute);
|
||||
this.entityType$ = routeEntityInfo$.pipe(map(info => {
|
||||
@@ -415,7 +417,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
|
||||
|
||||
this.currentFilterLabel = 'All Books';
|
||||
this.currentFilterLabel = this.t.translate('book.browser.labels.allBooks');
|
||||
const filterParams = queryParamMap.get('filter');
|
||||
|
||||
if (filterParams) {
|
||||
@@ -494,7 +496,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.rawFilterParamFromUrl = null;
|
||||
|
||||
const hasSidebarFilters = !!filters && Object.keys(filters).length > 0;
|
||||
this.currentFilterLabel = hasSidebarFilters ? this.computedFilterLabel : 'All Books';
|
||||
this.currentFilterLabel = hasSidebarFilters ? this.computedFilterLabel : this.t.translate('book.browser.labels.allBooks');
|
||||
|
||||
this.queryParamsService.updateFilters(filters);
|
||||
}
|
||||
@@ -558,18 +560,18 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
confirmDeleteBooks(): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?\n\nThis will permanently remove the book files from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.browser.confirm.deleteMessage', {count: this.selectedBooks.size}),
|
||||
header: this.t.translate('book.browser.confirm.deleteHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptLabel: this.t.translate('common.delete'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
const count = this.selectedBooks.size;
|
||||
const loader = this.loadingService.show(`Deleting ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.browser.loading.deleting', {count}));
|
||||
|
||||
this.bookService.deleteBooks(this.selectedBooks)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
@@ -735,10 +737,10 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.userService.updateUserSetting(user.id, 'entityViewPreferences', prefs);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Sort Saved',
|
||||
summary: this.t.translate('book.browser.toast.sortSavedSummary'),
|
||||
detail: this.entityType === EntityType.ALL_BOOKS || this.entityType === EntityType.UNSHELVED
|
||||
? 'Default sort configuration saved.'
|
||||
: `Sort configuration saved for this ${this.entityType.toLowerCase()}.`
|
||||
? this.t.translate('book.browser.toast.sortSavedGlobalDetail')
|
||||
: this.t.translate('book.browser.toast.sortSavedEntityDetail', {entityType: this.entityType.toLowerCase()})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -775,17 +777,17 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
unshelfBooks(): void {
|
||||
if (!this.entity) return;
|
||||
const count = this.selectedBooks.size;
|
||||
const loader = this.loadingService.show(`Unshelving ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.browser.loading.unshelving', {count}));
|
||||
|
||||
this.bookService.updateBookShelves(this.selectedBooks, new Set(), new Set([this.entity.id!]))
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Books shelves updated'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.browser.toast.unshelveSuccessDetail')});
|
||||
this.bookSelectionService.deselectAll();
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update books shelves'});
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('book.browser.toast.unshelveFailedDetail')});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -829,17 +831,17 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (!this.selectedBooks || this.selectedBooks.size === 0) return;
|
||||
const count = this.selectedBooks.size;
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to regenerate covers for ${count} book(s)?`,
|
||||
header: 'Confirm Cover Regeneration',
|
||||
message: this.t.translate('book.browser.confirm.regenCoverMessage', {count}),
|
||||
header: this.t.translate('book.browser.confirm.regenCoverHeader'),
|
||||
icon: 'pi pi-image',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'success'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'No',
|
||||
label: this.t.translate('common.no'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
@@ -847,16 +849,16 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Cover Regeneration Started',
|
||||
detail: `Regenerating covers for ${count} book(s). Refresh the page when complete.`,
|
||||
summary: this.t.translate('book.browser.toast.regenCoverStartedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.regenCoverStartedDetail', {count}),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not start cover regeneration.',
|
||||
summary: this.t.translate('book.browser.toast.failedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.regenCoverFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -869,17 +871,17 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (!this.selectedBooks || this.selectedBooks.size === 0) return;
|
||||
const count = this.selectedBooks.size;
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to generate custom covers for ${count} book(s)?`,
|
||||
header: 'Confirm Custom Cover Generation',
|
||||
message: this.t.translate('book.browser.confirm.customCoverMessage', {count}),
|
||||
header: this.t.translate('book.browser.confirm.customCoverHeader'),
|
||||
icon: 'pi pi-palette',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'success'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'No',
|
||||
label: this.t.translate('common.no'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
@@ -887,16 +889,16 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Custom Cover Generation Started',
|
||||
detail: `Generating custom covers for ${count} book(s).`,
|
||||
summary: this.t.translate('book.browser.toast.customCoverStartedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.customCoverStartedDetail', {count}),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not start custom cover generation.',
|
||||
summary: this.t.translate('book.browser.toast.failedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.customCoverFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -920,8 +922,8 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (sourceBooks.length === 0) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'No Eligible Books',
|
||||
detail: 'Selected books must be single-file books (no alternative formats).'
|
||||
summary: this.t.translate('book.browser.toast.noEligibleBooksSummary'),
|
||||
detail: this.t.translate('book.browser.toast.noEligibleBooksDetail')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -931,8 +933,8 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (libraryIds.size > 1) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Multiple Libraries',
|
||||
detail: 'All selected books must be from the same library.'
|
||||
summary: this.t.translate('book.browser.toast.multipleLibrariesSummary'),
|
||||
detail: this.t.translate('book.browser.toast.multipleLibrariesDetail')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
class="book-cover"
|
||||
[class.loaded]="isImageLoaded"
|
||||
[class.square-cover]="_isAudiobook"
|
||||
[alt]="'Cover of ' + displayTitle"
|
||||
[alt]="('book.card.alt.cover' | transloco: { title: displayTitle })"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(load)="onImageLoad()"/>
|
||||
|
||||
@@ -26,12 +26,13 @@ import {TaskHelperService} from '../../../../settings/task-management/task-helpe
|
||||
import {BookNavigationService} from '../../../service/book-navigation.service';
|
||||
import {BookCardOverlayPreferenceService} from '../book-card-overlay-preference.service';
|
||||
import {AppSettingsService} from '../../../../../shared/service/app-settings.service';
|
||||
import {TranslocoPipe, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-card',
|
||||
templateUrl: './book-card.component.html',
|
||||
styleUrls: ['./book-card.component.scss'],
|
||||
imports: [Button, MenuModule, CheckboxModule, FormsModule, NgClass, TieredMenu, ProgressBar, TooltipModule, RouterLink],
|
||||
imports: [Button, MenuModule, CheckboxModule, FormsModule, NgClass, TieredMenu, ProgressBar, TooltipModule, RouterLink, TranslocoPipe],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -70,6 +71,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private bookNavigationService = inject(BookNavigationService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
protected _progressPercentage: number | null = null;
|
||||
protected _koProgressPercentage: number | null = null;
|
||||
@@ -140,7 +142,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (changes['seriesViewEnabled'] || changes['isSeriesCollapsed']) {
|
||||
this._isSeriesViewActive = this.seriesViewEnabled && !!this.book.seriesCount && this.book.seriesCount >= 1;
|
||||
this._displayTitle = (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata?.seriesName : this.book.metadata?.title;
|
||||
this._titleTooltip = 'Title: ' + this._displayTitle;
|
||||
this._titleTooltip = this.t.translate('book.card.alt.titleTooltip', { title: this._displayTitle });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +171,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this._readStatusTooltip = this.readStatusHelper.getReadStatusTooltip(this.book.readStatus);
|
||||
this._shouldShowStatusIcon = this.readStatusHelper.shouldShowStatusIcon(this.book.readStatus);
|
||||
|
||||
this._seriesCountTooltip = 'Series collapsed: ' + this.book.seriesCount + ' books';
|
||||
this._titleTooltip = 'Title: ' + this._displayTitle;
|
||||
this._seriesCountTooltip = this.t.translate('book.card.alt.seriesCollapsed', { count: this.book.seriesCount });
|
||||
this._titleTooltip = this.t.translate('book.card.alt.titleTooltip', { title: this._displayTitle });
|
||||
}
|
||||
|
||||
get hasProgress(): boolean {
|
||||
@@ -273,12 +275,12 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private initMenu() {
|
||||
this.items = [
|
||||
{
|
||||
label: 'Assign Shelf',
|
||||
label: this.t.translate('book.card.menu.assignShelf'),
|
||||
icon: 'pi pi-folder',
|
||||
command: () => this.openShelfDialog()
|
||||
},
|
||||
{
|
||||
label: 'View Details',
|
||||
label: this.t.translate('book.card.menu.viewDetails'),
|
||||
icon: 'pi pi-info-circle',
|
||||
command: () => {
|
||||
setTimeout(() => {
|
||||
@@ -301,13 +303,13 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (hasAdditionalFiles) {
|
||||
const downloadItems = this.getDownloadMenuItems();
|
||||
items.push({
|
||||
label: 'Download',
|
||||
label: this.t.translate('book.card.menu.download'),
|
||||
icon: 'pi pi-download',
|
||||
items: downloadItems
|
||||
});
|
||||
} else if (this.additionalFilesLoaded) {
|
||||
items.push({
|
||||
label: 'Download',
|
||||
label: this.t.translate('book.card.menu.download'),
|
||||
icon: 'pi pi-download',
|
||||
command: () => {
|
||||
this.bookService.downloadFile(this.book);
|
||||
@@ -315,9 +317,9 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: 'Download',
|
||||
label: this.t.translate('book.card.menu.download'),
|
||||
icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-download',
|
||||
items: [{label: 'Loading...', disabled: true}]
|
||||
items: [{label: this.t.translate('book.card.menu.loading'), disabled: true}]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -329,23 +331,23 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (hasAdditionalFiles) {
|
||||
const deleteItems = this.getDeleteMenuItems();
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
label: this.t.translate('book.card.menu.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
items: deleteItems
|
||||
});
|
||||
} else if (this.additionalFilesLoaded) {
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
label: this.t.translate('book.card.menu.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.card.confirm.deleteBookMessage', {title: this.book.metadata?.title}),
|
||||
header: this.t.translate('book.card.confirm.deleteBookHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptLabel: this.t.translate('common.delete'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
@@ -356,9 +358,9 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
label: this.t.translate('book.card.menu.delete'),
|
||||
icon: this.isSubMenuLoading ? 'pi pi-spin pi-spinner' : 'pi pi-trash',
|
||||
items: [{label: 'Loading...', disabled: true}]
|
||||
items: [{label: this.t.translate('book.card.menu.loading'), disabled: true}]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -366,25 +368,25 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.user?.permissions.canEmailBook) {
|
||||
items.push(
|
||||
{
|
||||
label: 'Email Book',
|
||||
label: this.t.translate('book.card.menu.emailBook'),
|
||||
icon: 'pi pi-envelope',
|
||||
items: [{
|
||||
label: 'Quick Send',
|
||||
label: this.t.translate('book.card.menu.quickSend'),
|
||||
icon: 'pi pi-envelope',
|
||||
command: () => {
|
||||
this.emailService.emailBookQuick(this.book.id).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: 'Success',
|
||||
detail: 'The book sending has been scheduled.',
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.card.toast.quickSendSuccessDetail'),
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || 'An error occurred while sending the book.';
|
||||
const errorMessage = err?.error?.message || this.t.translate('book.card.toast.quickSendErrorDetail');
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: errorMessage,
|
||||
});
|
||||
},
|
||||
@@ -392,7 +394,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Custom Send',
|
||||
label: this.t.translate('book.card.menu.customSend'),
|
||||
icon: 'pi pi-envelope',
|
||||
command: () => {
|
||||
this.bookDialogHelperService.openCustomSendDialog(this.book);
|
||||
@@ -404,11 +406,11 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.user?.permissions.canEditMetadata) {
|
||||
items.push({
|
||||
label: 'Metadata',
|
||||
label: this.t.translate('book.card.menu.metadata'),
|
||||
icon: 'pi pi-database',
|
||||
items: [
|
||||
{
|
||||
label: 'Search Metadata',
|
||||
label: this.t.translate('book.card.menu.searchMetadata'),
|
||||
icon: 'pi pi-sparkles',
|
||||
command: () => {
|
||||
setTimeout(() => {
|
||||
@@ -419,7 +421,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Auto Fetch',
|
||||
label: this.t.translate('book.card.menu.autoFetch'),
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
this.taskHelperService.refreshMetadataTask({
|
||||
@@ -429,44 +431,44 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Custom Fetch',
|
||||
label: this.t.translate('book.card.menu.customFetch'),
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.bookDialogHelperService.openMetadataRefreshDialog(new Set([this.book!.id]))
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Regenerate Cover (File)',
|
||||
label: this.t.translate('book.card.menu.regenerateCover'),
|
||||
icon: 'pi pi-image',
|
||||
command: () => {
|
||||
this.bookService.regenerateCover(this.book.id).subscribe({
|
||||
next: () => this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Cover regeneration started'
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.card.toast.coverRegenSuccessDetail')
|
||||
}),
|
||||
error: (err) => this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: err?.error?.message || 'Failed to regenerate cover'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: err?.error?.message || this.t.translate('book.card.toast.coverRegenFailedDetail')
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Generate Custom Cover',
|
||||
label: this.t.translate('book.card.menu.generateCustomCover'),
|
||||
icon: 'pi pi-palette',
|
||||
command: () => {
|
||||
this.bookService.generateCustomCover(this.book.id).subscribe({
|
||||
next: () => this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Cover generated successfully'
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.card.toast.customCoverSuccessDetail')
|
||||
}),
|
||||
error: (err) => this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: err?.error?.message || 'Failed to generate cover'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: err?.error?.message || this.t.translate('book.card.toast.customCoverFailedDetail')
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -484,7 +486,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.user?.permissions.canMoveOrganizeFiles && this.diskType === 'LOCAL') {
|
||||
moreActions.push({
|
||||
label: 'Organize File',
|
||||
label: this.t.translate('book.card.menu.organizeFile'),
|
||||
icon: 'pi pi-arrows-h',
|
||||
command: () => {
|
||||
this.bookDialogHelperService.openFileMoverDialog(new Set([this.book.id]));
|
||||
@@ -494,7 +496,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
moreActions.push(
|
||||
{
|
||||
label: 'Read Status',
|
||||
label: this.t.translate('book.card.menu.readStatus'),
|
||||
icon: 'pi pi-book',
|
||||
items: Object.entries(readStatusLabels).map(([status, label]) => ({
|
||||
label,
|
||||
@@ -503,16 +505,16 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Read Status Updated',
|
||||
detail: `Marked as "${label}"`,
|
||||
summary: this.t.translate('book.card.toast.readStatusUpdatedSummary'),
|
||||
detail: this.t.translate('book.card.toast.readStatusUpdatedDetail', {label}),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: 'Could not update read status.',
|
||||
summary: this.t.translate('book.card.toast.readStatusFailedSummary'),
|
||||
detail: this.t.translate('book.card.toast.readStatusFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -521,23 +523,23 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Reset Booklore Progress',
|
||||
label: this.t.translate('book.card.menu.resetBookloreProgress'),
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.bookService.resetProgress(this.book.id, ResetProgressTypes.BOOKLORE).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Booklore reading progress has been reset.',
|
||||
summary: this.t.translate('book.card.toast.progressResetSummary'),
|
||||
detail: this.t.translate('book.card.toast.progressResetBookloreDetail'),
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset Booklore progress.',
|
||||
summary: this.t.translate('book.card.toast.progressResetFailedSummary'),
|
||||
detail: this.t.translate('book.card.toast.progressResetBookloreFailedDetail'),
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
@@ -545,23 +547,23 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Reset KOReader Progress',
|
||||
label: this.t.translate('book.card.menu.resetKOReaderProgress'),
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.bookService.resetProgress(this.book.id, ResetProgressTypes.KOREADER).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'KOReader reading progress has been reset.',
|
||||
summary: this.t.translate('book.card.toast.progressResetSummary'),
|
||||
detail: this.t.translate('book.card.toast.progressResetKOReaderDetail'),
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not reset KOReader progress.',
|
||||
summary: this.t.translate('book.card.toast.progressResetFailedSummary'),
|
||||
detail: this.t.translate('book.card.toast.progressResetKOReaderFailedDetail'),
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
@@ -571,7 +573,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
|
||||
items.push({
|
||||
label: 'More Actions',
|
||||
label: this.t.translate('book.card.menu.moreActions'),
|
||||
icon: 'pi pi-ellipsis-h',
|
||||
items: moreActions
|
||||
});
|
||||
@@ -657,17 +659,17 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
items.push({
|
||||
label: 'Book',
|
||||
label: this.t.translate('book.card.menu.book'),
|
||||
icon: 'pi pi-book',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.card.confirm.deleteBookMessage', {title: this.book.metadata?.title}),
|
||||
header: this.t.translate('book.card.confirm.deleteBookHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptLabel: this.t.translate('common.delete'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
@@ -722,8 +724,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private deleteAdditionalFile(bookId: number, fileId: number, fileName: string): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete the additional file "${fileName}"?`,
|
||||
header: 'Confirm File Deletion',
|
||||
message: this.t.translate('book.card.confirm.deleteFileMessage', {fileName}),
|
||||
header: this.t.translate('book.card.confirm.deleteFileHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
@@ -733,15 +735,15 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: `Additional file "${fileName}" deleted successfully`
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.card.toast.deleteFileSuccessDetail', {fileName})
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: `Failed to delete additional file: ${error.message || 'Unknown error'}`
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('book.card.toast.deleteFileErrorDetail', {error: error.message || 'Unknown error'})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.filter'">
|
||||
<div class="book-filter-container">
|
||||
|
||||
<div class="filter-header">
|
||||
<span class="filter-title">Filters</span>
|
||||
<span class="filter-title">{{ t('title') }}</span>
|
||||
<p-selectButton
|
||||
[options]="filterModeOptions"
|
||||
[(ngModel)]="selectedFilterMode"
|
||||
@@ -50,7 +51,7 @@
|
||||
</cdk-virtual-scroll-viewport>
|
||||
@if (truncatedFilters[filterType]) {
|
||||
<div class="truncation-notice">
|
||||
Showing first 100 items
|
||||
{{ t('showingFirst100') }}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -62,7 +63,8 @@
|
||||
</p-accordion>
|
||||
|
||||
<div class="footer-notice">
|
||||
Note: Top 100 items are displayed per filter category
|
||||
{{ t('footerNote') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {MagicShelf} from '../../../../magic-shelf/service/magic-shelf.service';
|
||||
import {Filter, FILTER_LABELS, FilterType} from './book-filter.config';
|
||||
import {BookFilterService} from './book-filter.service';
|
||||
import {filter} from 'rxjs/operators';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
|
||||
type FilterModeOption = { label: string; value: BookFilterMode };
|
||||
|
||||
@@ -26,7 +27,8 @@ type FilterModeOption = { label: string; value: BookFilterMode };
|
||||
imports: [
|
||||
Accordion, AccordionPanel, AccordionHeader, AccordionContent,
|
||||
CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf,
|
||||
NgClass, Badge, AsyncPipe, TitleCasePipe, FormsModule, SelectButton
|
||||
NgClass, Badge, AsyncPipe, TitleCasePipe, FormsModule, SelectButton,
|
||||
TranslocoDirective
|
||||
]
|
||||
})
|
||||
export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.table'">
|
||||
<p-table
|
||||
[value]="books"
|
||||
[columns]="visibleColumns"
|
||||
@@ -42,7 +43,7 @@
|
||||
[icon]="isMetadataFullyLocked(metadata) ? 'pi pi-lock' : 'pi pi-lock-open'"
|
||||
[severity]="isMetadataFullyLocked(metadata) ? 'danger' : 'success'"
|
||||
[text]="true"
|
||||
[title]="isMetadataFullyLocked(metadata) ? 'Locked' : 'Unlocked'"
|
||||
[title]="isMetadataFullyLocked(metadata) ? t('locked') : t('unlocked')"
|
||||
size="small"
|
||||
[style]="{ width: '1.5rem', height: '1.5rem', padding: '0', fontSize: '0.75rem' }"
|
||||
(click)="toggleMetadataLock(metadata)">
|
||||
@@ -52,7 +53,7 @@
|
||||
<a [routerLink]="urlHelper.getBookUrl(book)">
|
||||
<img
|
||||
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
|
||||
alt="Book Cover"
|
||||
[alt]="t('bookCoverAlt')"
|
||||
class="cover-thumbnail"
|
||||
tooltipPosition="left"
|
||||
[pTooltip]="tooltipContent"
|
||||
@@ -63,7 +64,7 @@
|
||||
<div class="tooltip-cover-container">
|
||||
<img
|
||||
[attr.src]="urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn)"
|
||||
alt="Book Cover"
|
||||
[alt]="t('bookCoverAlt')"
|
||||
class="tooltip-cover-image"
|
||||
/>
|
||||
<em class="tooltip-cover-title">{{ metadata.title }}</em>
|
||||
@@ -77,7 +78,7 @@
|
||||
@if (shouldShowStatusIcon(book.readStatus)) {
|
||||
<div class="read-status-indicator"
|
||||
[ngClass]="getReadStatusClass(book.readStatus)"
|
||||
[pTooltip]="'Status: ' + getReadStatusTooltip(book.readStatus)"
|
||||
[pTooltip]="t('statusPrefix') + getReadStatusTooltip(book.readStatus)"
|
||||
tooltipPosition="top">
|
||||
<i [class]="getReadStatusIcon(book.readStatus)"></i>
|
||||
</div>
|
||||
@@ -116,3 +117,4 @@
|
||||
}
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</ng-container>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {filter, Subject} from 'rxjs';
|
||||
import {UserService} from '../../../../settings/user-management/user.service';
|
||||
import {take, takeUntil} from 'rxjs/operators';
|
||||
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-table',
|
||||
@@ -27,7 +28,8 @@ import {ReadStatusHelper} from '../../../helpers/read-status.helper';
|
||||
Button,
|
||||
TooltipModule,
|
||||
NgClass,
|
||||
RouterLink
|
||||
RouterLink,
|
||||
TranslocoDirective
|
||||
],
|
||||
styleUrls: ['./book-table.component.scss'],
|
||||
providers: [DatePipe]
|
||||
@@ -48,6 +50,7 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
|
||||
private userService = inject(UserService);
|
||||
private datePipe = inject(DatePipe);
|
||||
private readStatusHelper = inject(ReadStatusHelper);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -327,15 +330,15 @@ export class BookTableComponent implements OnInit, OnDestroy, OnChanges {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: `Metadata ${lockAction === 'LOCK' ? 'Locked' : 'Unlocked'}`,
|
||||
detail: `Book metadata has been ${lockAction === 'LOCK' ? 'locked' : 'unlocked'} successfully.`,
|
||||
summary: lockAction === 'LOCK' ? this.t.translate('book.table.toast.metadataLockedSummary') : this.t.translate('book.table.toast.metadataUnlockedSummary'),
|
||||
detail: lockAction === 'LOCK' ? this.t.translate('book.table.toast.metadataLockedDetail') : this.t.translate('book.table.toast.metadataUnlockedDetail'),
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: `Failed to ${lockAction === 'LOCK' ? 'Lock' : 'Unlock'}`,
|
||||
detail: `An error occurred while ${lockAction === 'LOCK' ? 'locking' : 'unlocking'} the metadata.`,
|
||||
summary: lockAction === 'LOCK' ? this.t.translate('book.table.toast.lockFailedSummary') : this.t.translate('book.table.toast.unlockFailedSummary'),
|
||||
detail: lockAction === 'LOCK' ? this.t.translate('book.table.toast.lockFailedDetail') : this.t.translate('book.table.toast.unlockFailedDetail'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {inject, Injectable} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {debounceTime} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {LocalStorageService} from '../../../../shared/service/local-storage.service';
|
||||
import {Book} from '../../model/book.model';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class CoverScalePreferenceService {
|
||||
private readonly STORAGE_KEY = 'coverScalePreference';
|
||||
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private readonly localStorageService = inject(LocalStorageService);
|
||||
|
||||
private readonly scaleChangeSubject = new Subject<number>();
|
||||
@@ -64,15 +66,15 @@ export class CoverScalePreferenceService {
|
||||
this.localStorageService.set(this.STORAGE_KEY, scale);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Cover Size Saved',
|
||||
detail: `Cover size set to ${scale.toFixed(2)}x.`,
|
||||
summary: this.t.translate('book.coverPref.toast.savedSummary'),
|
||||
detail: this.t.translate('book.coverPref.toast.savedDetail', {scale: scale.toFixed(2)}),
|
||||
life: 1500
|
||||
});
|
||||
} catch (e) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Save Failed',
|
||||
detail: 'Could not save cover size preference locally.',
|
||||
summary: this.t.translate('book.coverPref.toast.saveFailedSummary'),
|
||||
detail: this.t.translate('book.coverPref.toast.saveFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {LocalStorageService} from '../../../../../shared/service/local-storage.service';
|
||||
|
||||
@Injectable({
|
||||
@@ -10,6 +11,7 @@ export class SidebarFilterTogglePrefService {
|
||||
|
||||
private readonly STORAGE_KEY = 'showSidebarFilter';
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private readonly localStorageService = inject(LocalStorageService);
|
||||
|
||||
private readonly showFilterSubject = new BehaviorSubject<boolean>(true);
|
||||
@@ -43,8 +45,8 @@ export class SidebarFilterTogglePrefService {
|
||||
} catch (e) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Save Failed',
|
||||
detail: 'Could not save sidebar filter preference locally.',
|
||||
summary: this.t.translate('book.filterPref.toast.saveFailedSummary'),
|
||||
detail: this.t.translate('book.filterPref.toast.saveFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.lockUnlockDialog'">
|
||||
<div class="lock-unlock-container">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-lock header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Lock or Unlock Metadata</h2>
|
||||
<p class="panel-description">{{ bookIds.size }} book{{ bookIds.size > 1 ? 's' : '' }} selected</p>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">{{ t('selectedCount', { count: bookIds.size }) }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -36,13 +37,14 @@
|
||||
|
||||
<div class="dialog-footer">
|
||||
<div class="footer-actions">
|
||||
<p-button icon="pi pi-refresh" label="Reset" outlined="true" severity="warn" (onClick)="resetFieldLocks()"></p-button>
|
||||
<p-button icon="pi pi-refresh" [label]="t('reset')" outlined="true" severity="warn" (onClick)="resetFieldLocks()"></p-button>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<p-button icon="pi pi-lock" label="Lock All" outlined="true" severity="danger" (onClick)="toggleLockAll('LOCK')"></p-button>
|
||||
<p-button icon="pi pi-lock-open" label="Unlock All" outlined="true" severity="success" (onClick)="toggleLockAll('UNLOCK')"></p-button>
|
||||
<p-button icon="pi pi-lock" [label]="t('lockAll')" outlined="true" severity="danger" (onClick)="toggleLockAll('LOCK')"></p-button>
|
||||
<p-button icon="pi pi-lock-open" [label]="t('unlockAll')" outlined="true" severity="success" (onClick)="toggleLockAll('UNLOCK')"></p-button>
|
||||
<p-divider layout="vertical"></p-divider>
|
||||
<p-button [label]="isSaving ? 'Saving...' : 'Save'" [outlined]="true" icon="pi pi-check" severity="info" (onClick)="applyFieldLocks()" [disabled]="bookIds.size === 0 || isSaving"></p-button>
|
||||
<p-button [label]="isSaving ? t('saving') : t('save')" [outlined]="true" icon="pi pi-check" severity="info" (onClick)="applyFieldLocks()" [disabled]="bookIds.size === 0 || isSaving"></p-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {BookService} from '../../../service/book.service';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {LoadingService} from '../../../../../core/services/loading.service';
|
||||
import {finalize} from 'rxjs';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lock-unlock-metadata-dialog',
|
||||
@@ -15,7 +16,8 @@ import {finalize} from 'rxjs';
|
||||
imports: [
|
||||
Button,
|
||||
FormsModule,
|
||||
Divider
|
||||
Divider,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './lock-unlock-metadata-dialog.component.html',
|
||||
styleUrl: './lock-unlock-metadata-dialog.component.scss'
|
||||
@@ -26,6 +28,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
|
||||
dialogRef = inject(DynamicDialogRef);
|
||||
private messageService = inject(MessageService);
|
||||
private loadingService = inject(LoadingService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
fieldLocks: Record<string, boolean | undefined> = {};
|
||||
|
||||
bookIds: Set<number> = this.dynamicDialogConfig.data.bookIds;
|
||||
@@ -89,8 +92,8 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
|
||||
|
||||
getLockLabel(field: string): string {
|
||||
const state = this.fieldLocks[field];
|
||||
if (state === undefined) return 'Unselected';
|
||||
return state ? 'Locked' : 'Unlocked';
|
||||
if (state === undefined) return this.t.translate('book.lockUnlockDialog.unselected');
|
||||
return state ? this.t.translate('book.lockUnlockDialog.locked') : this.t.translate('book.lockUnlockDialog.unlocked');
|
||||
}
|
||||
|
||||
getLockIcon(field: string): string {
|
||||
@@ -124,7 +127,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
const loader = this.loadingService.show('Updating field locks...');
|
||||
const loader = this.loadingService.show(this.t.translate('book.lockUnlockDialog.toast.updatingFieldLocks'));
|
||||
|
||||
this.bookService.toggleFieldLocks(this.bookIds, fieldActions)
|
||||
.pipe(finalize(() => {
|
||||
@@ -135,16 +138,16 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Field Locks Updated',
|
||||
detail: 'Selected metadata fields have been updated successfully.'
|
||||
summary: this.t.translate('book.lockUnlockDialog.toast.updatedSummary'),
|
||||
detail: this.t.translate('book.lockUnlockDialog.toast.updatedDetail')
|
||||
});
|
||||
this.dialogRef.close('fields-updated');
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to Update Field Locks',
|
||||
detail: 'An error occurred while updating field lock statuses.'
|
||||
summary: this.t.translate('book.lockUnlockDialog.toast.failedSummary'),
|
||||
detail: this.t.translate('book.lockUnlockDialog.toast.failedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.sorting'">
|
||||
<div class="multi-sort-container">
|
||||
<div class="multi-sort-header">
|
||||
<span class="header-title">Sort Order</span>
|
||||
<span class="header-title">{{ t('sortOrder') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -28,7 +29,7 @@
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
(click)="onRemove(i)"
|
||||
pTooltip="Remove"
|
||||
[pTooltip]="t('removeTooltip')"
|
||||
tooltipPosition="top"
|
||||
[disabled]="sortCriteria.length === 1">
|
||||
<i class="pi pi-times"></i>
|
||||
@@ -45,7 +46,7 @@
|
||||
[(ngModel)]="selectedField"
|
||||
optionLabel="label"
|
||||
optionValue="field"
|
||||
placeholder="Add sort field..."
|
||||
[placeholder]="t('addSortFieldPlaceholder')"
|
||||
[style]="{ width: '100%' }"
|
||||
size="small"
|
||||
appendTo="body"
|
||||
@@ -58,7 +59,7 @@
|
||||
<div class="save-sort-section">
|
||||
<p-button
|
||||
icon="pi pi-save"
|
||||
label="Save as Default"
|
||||
[label]="t('saveAsDefault')"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
[style]="{ width: '100%' }"
|
||||
@@ -67,3 +68,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Input, Output} from '@angular/core';
|
||||
import {SortDirection, SortOption} from '../../../../model/sort.model';
|
||||
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
import {Select} from 'primeng/select';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {Button} from 'primeng/button';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-multi-sort-popover',
|
||||
@@ -16,12 +17,14 @@ import {Button} from 'primeng/button';
|
||||
Select,
|
||||
FormsModule,
|
||||
Tooltip,
|
||||
Button
|
||||
Button,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './multi-sort-popover.component.html',
|
||||
styleUrl: './multi-sort-popover.component.scss'
|
||||
})
|
||||
export class MultiSortPopoverComponent {
|
||||
private readonly t = inject(TranslocoService);
|
||||
@Input() sortCriteria: SortOption[] = [];
|
||||
@Input() availableSortOptions: SortOption[] = [];
|
||||
@Input() showSaveButton = false;
|
||||
@@ -79,7 +82,9 @@ export class MultiSortPopoverComponent {
|
||||
}
|
||||
|
||||
getDirectionTooltip(direction: SortDirection): string {
|
||||
return direction === SortDirection.ASCENDING ? 'Ascending - click to change' : 'Descending - click to change';
|
||||
return direction === SortDirection.ASCENDING
|
||||
? this.t.translate('book.sorting.ascendingTooltip')
|
||||
: this.t.translate('book.sorting.descendingTooltip');
|
||||
}
|
||||
|
||||
protected readonly SortDirection = SortDirection;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {TableColumnPreference, UserService} from '../../../settings/user-management/user.service';
|
||||
|
||||
@Injectable({
|
||||
@@ -9,37 +10,21 @@ import {TableColumnPreference, UserService} from '../../../settings/user-managem
|
||||
export class TableColumnPreferenceService {
|
||||
private readonly userService = inject(UserService);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
private readonly preferencesSubject = new BehaviorSubject<TableColumnPreference[]>([]);
|
||||
readonly preferences$ = this.preferencesSubject.asObservable();
|
||||
|
||||
private readonly allAvailableColumns = [
|
||||
{field: 'readStatus', header: 'Read'},
|
||||
{field: 'title', header: 'Title'},
|
||||
{field: 'authors', header: 'Authors'},
|
||||
{field: 'publisher', header: 'Publisher'},
|
||||
{field: 'seriesName', header: 'Series'},
|
||||
{field: 'seriesNumber', header: 'Series #'},
|
||||
{field: 'categories', header: 'Genres'},
|
||||
{field: 'publishedDate', header: 'Published'},
|
||||
{field: 'lastReadTime', header: 'Last Read'},
|
||||
{field: 'addedOn', header: 'Added'},
|
||||
{field: 'fileName', header: 'File Name'},
|
||||
{field: 'fileSizeKb', header: 'File Size'},
|
||||
{field: 'language', header: 'Language'},
|
||||
{field: 'isbn', header: 'ISBN'},
|
||||
{field: 'pageCount', header: 'Pages'},
|
||||
{field: 'amazonRating', header: 'Amazon'},
|
||||
{field: 'amazonReviewCount', header: 'AZ #'},
|
||||
{field: 'goodreadsRating', header: 'Goodreads'},
|
||||
{field: 'goodreadsReviewCount', header: 'GR #'},
|
||||
{field: 'hardcoverRating', header: 'Hardcover'},
|
||||
{field: 'hardcoverReviewCount', header: 'HC #'},
|
||||
{field: 'ranobedbRating', header: 'Ranobedb'},
|
||||
private readonly allAvailableFields = [
|
||||
'readStatus', 'title', 'authors', 'publisher', 'seriesName', 'seriesNumber',
|
||||
'categories', 'publishedDate', 'lastReadTime', 'addedOn', 'fileName', 'fileSizeKb',
|
||||
'language', 'isbn', 'pageCount', 'amazonRating', 'amazonReviewCount',
|
||||
'goodreadsRating', 'goodreadsReviewCount', 'hardcoverRating', 'hardcoverReviewCount',
|
||||
'ranobedbRating',
|
||||
];
|
||||
|
||||
private readonly fallbackPreferences: TableColumnPreference[] = this.allAvailableColumns.map((col, index) => ({
|
||||
field: col.field,
|
||||
private readonly fallbackPreferences: TableColumnPreference[] = this.allAvailableFields.map((field, index) => ({
|
||||
field,
|
||||
visible: true,
|
||||
order: index
|
||||
}));
|
||||
@@ -50,7 +35,10 @@ export class TableColumnPreferenceService {
|
||||
}
|
||||
|
||||
get allColumns(): { field: string; header: string }[] {
|
||||
return this.allAvailableColumns;
|
||||
return this.allAvailableFields.map(field => ({
|
||||
field,
|
||||
header: this.t.translate(`book.columnPref.columns.${field}`)
|
||||
}));
|
||||
}
|
||||
|
||||
get visibleColumns(): { field: string; header: string }[] {
|
||||
@@ -59,7 +47,7 @@ export class TableColumnPreferenceService {
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(pref => ({
|
||||
field: pref.field,
|
||||
header: this.getColumnHeader(pref.field)
|
||||
header: this.t.translate(`book.columnPref.columns.${pref.field}`)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -70,11 +58,11 @@ export class TableColumnPreferenceService {
|
||||
saveVisibleColumns(selectedColumns: { field: string }[]): void {
|
||||
const selectedFieldSet = new Set(selectedColumns.map(c => c.field));
|
||||
|
||||
const updatedPreferences: TableColumnPreference[] = this.allAvailableColumns.map((col, index) => {
|
||||
const selectionIndex = selectedColumns.findIndex(c => c.field === col.field);
|
||||
const updatedPreferences: TableColumnPreference[] = this.allAvailableFields.map((field, index) => {
|
||||
const selectionIndex = selectedColumns.findIndex(c => c.field === field);
|
||||
return {
|
||||
field: col.field,
|
||||
visible: selectedFieldSet.has(col.field),
|
||||
field,
|
||||
visible: selectedFieldSet.has(field),
|
||||
order: selectionIndex >= 0 ? selectionIndex : index
|
||||
};
|
||||
});
|
||||
@@ -88,23 +76,19 @@ export class TableColumnPreferenceService {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Preferences Saved',
|
||||
detail: 'Your column layout has been saved.',
|
||||
summary: this.t.translate('book.columnPref.toast.savedSummary'),
|
||||
detail: this.t.translate('book.columnPref.toast.savedDetail'),
|
||||
life: 1500
|
||||
});
|
||||
}
|
||||
|
||||
private getColumnHeader(field: string): string {
|
||||
return this.allAvailableColumns.find(col => col.field === field)?.header ?? field;
|
||||
}
|
||||
|
||||
private mergeWithAllColumns(savedPrefs: TableColumnPreference[]): TableColumnPreference[] {
|
||||
const savedPrefMap = new Map(savedPrefs.map(p => [p.field, p]));
|
||||
|
||||
return this.allAvailableColumns.map((col, index) => {
|
||||
const saved = savedPrefMap.get(col.field);
|
||||
return this.allAvailableFields.map((field, index) => {
|
||||
const saved = savedPrefMap.get(field);
|
||||
return {
|
||||
field: col.field,
|
||||
field,
|
||||
visible: saved?.visible ?? true,
|
||||
order: saved?.order ?? index
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.fileAttacher'">
|
||||
<div class="book-file-attacher-container">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-link header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Attach {{ isBulkMode ? 'Files' : 'File' }} to Another Book</h2>
|
||||
<p class="panel-description">Move {{ isBulkMode ? 'these books\' files' : 'this book\'s file' }} to another book as alternative format{{ isBulkMode ? 's' : '' }}</p>
|
||||
<h2 class="panel-title">{{ isBulkMode ? t('titleBulk') : t('title') }}</h2>
|
||||
<p class="panel-description">{{ isBulkMode ? t('descriptionBulk') : t('description') }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -20,11 +21,11 @@
|
||||
<div class="dialog-content">
|
||||
<!-- Source Book(s) Info -->
|
||||
<div class="source-info">
|
||||
<h3 class="section-title">Source Book{{ isBulkMode ? 's' : '' }} ({{ sourceBooks.length }})</h3>
|
||||
<h3 class="section-title">{{ isBulkMode ? t('sourceBooksLabel', { count: sourceBooks.length }) : t('sourceBookLabel', { count: sourceBooks.length }) }}</h3>
|
||||
<div class="source-books-list">
|
||||
@for (book of sourceBooks; track book.id) {
|
||||
<div class="book-info-card">
|
||||
<div class="book-title">{{ book.metadata?.title || 'Unknown Title' }}</div>
|
||||
<div class="book-title">{{ book.metadata?.title || t('unknownTitle') }}</div>
|
||||
@if (book.metadata?.authors?.length) {
|
||||
<div class="book-authors">{{ book.metadata?.authors?.join(', ') }}</div>
|
||||
}
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
<!-- Target Book Selection -->
|
||||
<div class="target-selection">
|
||||
<h3 class="section-title">Select Target Book</h3>
|
||||
<h3 class="section-title">{{ t('selectTargetBook') }}</h3>
|
||||
<p-autocomplete
|
||||
[(ngModel)]="targetBook"
|
||||
[suggestions]="filteredBooks"
|
||||
@@ -49,12 +50,12 @@
|
||||
optionLabel="metadata.title"
|
||||
[dropdown]="true"
|
||||
[showClear]="true"
|
||||
placeholder="Search for a book in the same library..."
|
||||
[placeholder]="t('searchPlaceholder')"
|
||||
class="full-width"
|
||||
[disabled]="isAttaching">
|
||||
<ng-template let-book pTemplate="item">
|
||||
<div class="book-suggestion">
|
||||
<div class="suggestion-title">{{ book.metadata?.title || 'Unknown Title' }}</div>
|
||||
<div class="suggestion-title">{{ book.metadata?.title || t('unknownTitle') }}</div>
|
||||
@if (book.metadata?.authors?.length) {
|
||||
<div class="suggestion-authors">{{ book.metadata.authors.join(', ') }}</div>
|
||||
}
|
||||
@@ -78,7 +79,7 @@
|
||||
[disabled]="isAttaching">
|
||||
</p-checkbox>
|
||||
<label for="deleteSourceBooks" class="delete-label">
|
||||
Delete source book{{ isBulkMode ? 's' : '' }} after attachment
|
||||
{{ isBulkMode ? t('deleteSourceBulk') : t('deleteSource') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -86,11 +87,11 @@
|
||||
<div class="warning-message">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<div class="warning-text">
|
||||
<p>This will move {{ isBulkMode ? 'the files from the selected books' : 'the file from the source book' }} to the target book as alternative format{{ isBulkMode ? 's' : '' }}.</p>
|
||||
<p>{{ isBulkMode ? t('warningMoveBulk') : t('warningMove') }}</p>
|
||||
@if (deleteSourceBooks) {
|
||||
<p>The source book{{ isBulkMode ? ' records' : ' record' }} will be deleted (file{{ isBulkMode ? 's' : '' }} will be preserved in target book).</p>
|
||||
<p>{{ isBulkMode ? t('warningDeleteBulk') : t('warningDelete') }}</p>
|
||||
} @else {
|
||||
<p>The source book{{ isBulkMode ? ' records' : ' record' }} will remain but will have no readable files.</p>
|
||||
<p>{{ isBulkMode ? t('warningKeepBulk') : t('warningKeep') }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,14 +99,14 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
[label]="'common.cancel' | transloco"
|
||||
[outlined]="true"
|
||||
severity="secondary"
|
||||
(click)="closeDialog()"
|
||||
[disabled]="isAttaching">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Attach {{ isBulkMode ? 'Files' : 'File' }}"
|
||||
[label]="isBulkMode ? t('attachFilesBulk') : t('attachFile')"
|
||||
icon="pi pi-link"
|
||||
(click)="attach()"
|
||||
[disabled]="!canAttach()"
|
||||
@@ -114,3 +115,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
|
||||
import { AutoComplete, AutoCompleteSelectEvent } from 'primeng/autocomplete';
|
||||
@@ -9,6 +9,7 @@ import { filter, take } from 'rxjs/operators';
|
||||
import { BookService } from '../../service/book.service';
|
||||
import { Book } from '../../model/book.model';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { TranslocoDirective, TranslocoPipe, TranslocoService } from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-file-attacher',
|
||||
@@ -17,7 +18,9 @@ import { MessageService } from 'primeng/api';
|
||||
FormsModule,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
TranslocoDirective,
|
||||
TranslocoPipe,
|
||||
],
|
||||
templateUrl: './book-file-attacher.component.html',
|
||||
styleUrls: ['./book-file-attacher.component.scss']
|
||||
@@ -33,6 +36,8 @@ export class BookFileAttacherComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private allBooks: Book[] = [];
|
||||
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
constructor(
|
||||
private dialogRef: DynamicDialogRef,
|
||||
private config: DynamicDialogConfig,
|
||||
@@ -110,9 +115,9 @@ export class BookFileAttacherComponent implements OnInit, OnDestroy {
|
||||
|
||||
getSourceFileInfo(book: Book): string {
|
||||
const file = book.primaryFile;
|
||||
if (!file) return 'Unknown file';
|
||||
const format = file.extension?.toUpperCase() || file.bookType || 'Unknown';
|
||||
return `${format} - ${file.fileName || 'Unknown filename'}`;
|
||||
if (!file) return this.t.translate('book.fileAttacher.unknownFile');
|
||||
const format = file.extension?.toUpperCase() || file.bookType || this.t.translate('book.fileAttacher.unknownFormat');
|
||||
return `${format} - ${file.fileName || this.t.translate('book.fileAttacher.unknownFilename')}`;
|
||||
}
|
||||
|
||||
canAttach(): boolean {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {ConfirmDialog} from 'primeng/confirmdialog';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {BookNote, BookNoteService, CreateBookNoteRequest} from '../../../../shared/service/book-note.service';
|
||||
|
||||
@Component({
|
||||
@@ -36,6 +37,7 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private messageService = inject(MessageService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
notes: BookNote[] = [];
|
||||
loading = false;
|
||||
@@ -85,8 +87,8 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
this.loading = false;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load notes for this book.'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('book.notes.toast.loadFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -116,8 +118,8 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
if (!this.newNote.title.trim() || !this.newNote.content.trim()) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validation Error',
|
||||
detail: 'Both title and content are required.'
|
||||
summary: this.t.translate('book.notes.toast.validationSummary'),
|
||||
detail: this.t.translate('book.notes.toast.validationDetail')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -130,16 +132,16 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
this.showCreateDialog = false;
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Note created successfully.'
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.notes.toast.createSuccessDetail')
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to create note:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to create note.'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('book.notes.toast.createFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -149,8 +151,8 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
if (!this.editNote.title?.trim() || !this.editNote.content?.trim()) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validation Error',
|
||||
detail: 'Both title and content are required.'
|
||||
summary: this.t.translate('book.notes.toast.validationSummary'),
|
||||
detail: this.t.translate('book.notes.toast.validationDetail')
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -170,16 +172,16 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
this.selectedNote = null;
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Note updated successfully.'
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.notes.toast.updateSuccessDetail')
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to update note:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to update note.'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('book.notes.toast.updateFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -188,8 +190,8 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
deleteNote(note: BookNote): void {
|
||||
this.confirmationService.confirm({
|
||||
key: 'deleteNote',
|
||||
message: `Are you sure you want to delete the note "${note.title}"?`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.notes.confirm.deleteMessage', {title: note.title}),
|
||||
header: this.t.translate('book.notes.confirm.deleteHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
@@ -207,16 +209,16 @@ export class BookNotesComponent implements OnInit, OnChanges {
|
||||
this.notes = this.notes.filter(n => n.id !== noteId);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Success',
|
||||
detail: 'Note deleted successfully.'
|
||||
summary: this.t.translate('common.success'),
|
||||
detail: this.t.translate('book.notes.toast.deleteSuccessDetail')
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to delete note:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to delete note.'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('book.notes.toast.deleteFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.reviews'">
|
||||
<div class="book-reviews-container">
|
||||
<div class="reviews-content">
|
||||
@if (loading) {
|
||||
<div class="loading-state">
|
||||
<p-progressSpinner/>
|
||||
<span class="loading-text">Getting latest reviews...</span>
|
||||
<span class="loading-text">{{ t('labels.loadingReviews') }}</span>
|
||||
</div>
|
||||
} @else if (reviews?.length === 0) {
|
||||
<div class="empty-state">
|
||||
@@ -15,20 +16,20 @@
|
||||
icon="pi pi-download"
|
||||
severity="primary"
|
||||
(click)="fetchNewReviews()"
|
||||
pTooltip="Fetch Reviews"
|
||||
[pTooltip]="t('tooltip.fetchReviews')"
|
||||
tooltipPosition="top"
|
||||
class="action-btn floating-btn">
|
||||
</p-button>
|
||||
}
|
||||
</div>
|
||||
<p class="empty-state-title">No reviews available for this book</p>
|
||||
<p class="empty-state-title">{{ t('empty.noReviews') }}</p>
|
||||
@if (!reviewDownloadEnabled) {
|
||||
<p class="empty-state-subtitle empty-state-subtitle--warning">
|
||||
Book review downloads are currently disabled. Enable this in Metadata Settings to fetch reviews.
|
||||
{{ t('empty.downloadsDisabled') }}
|
||||
</p>
|
||||
} @else if (hasPermission && !reviewsLocked) {
|
||||
<p class="empty-state-subtitle">
|
||||
Click "Fetch Reviews" to download reviews from configured providers
|
||||
{{ t('empty.fetchPrompt') }}
|
||||
</p>
|
||||
}
|
||||
<div class="empty-state-decoration"></div>
|
||||
@@ -40,7 +41,7 @@
|
||||
<div [class]="'review-card' + (!review.title ? ' no-title' : '') + (review.spoiler && !isSpoilerRevealed(review.id!) ? ' has-spoiler' : '')">
|
||||
@if (review.spoiler && !isSpoilerRevealed(review.id!)) {
|
||||
<p-button
|
||||
label="Show Spoiler"
|
||||
[label]="t('labels.showSpoiler')"
|
||||
icon="pi pi-eye"
|
||||
severity="warn"
|
||||
size="small"
|
||||
@@ -52,7 +53,7 @@
|
||||
<div class="review-header-inner">
|
||||
<div class="reviewer-info-group">
|
||||
<div class="reviewer-name-row">
|
||||
<span class="reviewer-name">{{ review.reviewerName || 'Anonymous' }}</span>
|
||||
<span class="reviewer-name">{{ review.reviewerName || t('labels.anonymous') }}</span>
|
||||
@if (review.metadataProvider) {
|
||||
<p-tag
|
||||
[rounded]="true"
|
||||
@@ -63,7 +64,7 @@
|
||||
<p-tag [rounded]="true" [value]="review.country" severity="info"/>
|
||||
}
|
||||
@if (review.spoiler) {
|
||||
<p-tag [rounded]="true" value="Spoiler" severity="warn" icon="pi pi-exclamation-triangle"/>
|
||||
<p-tag [rounded]="true" [value]="t('labels.spoiler')" severity="warn" icon="pi pi-exclamation-triangle"/>
|
||||
}
|
||||
</div>
|
||||
<div class="review-meta">
|
||||
@@ -89,7 +90,7 @@
|
||||
@if (reviewsLocked) {
|
||||
<div class="lock-icon-container">
|
||||
<i class="pi pi-lock lock-icon"
|
||||
pTooltip="Reviews are locked"
|
||||
[pTooltip]="t('tooltip.reviewsLocked')"
|
||||
tooltipPosition="top"></i>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -99,7 +100,7 @@
|
||||
severity="danger"
|
||||
text
|
||||
(onClick)="deleteReview(review)"
|
||||
pTooltip="Delete Review"
|
||||
[pTooltip]="t('tooltip.deleteReview')"
|
||||
tooltipPosition="top"
|
||||
class="action-btn-hover"/>
|
||||
}
|
||||
@@ -126,14 +127,14 @@
|
||||
@if (review.body) {
|
||||
<div class="review-body review-body--blurred">{{ review.body }}</div>
|
||||
} @else {
|
||||
<div class="review-empty-text review-empty-text--blurred">No review content available</div>
|
||||
<div class="review-empty-text review-empty-text--blurred">{{ t('empty.noContent') }}</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (review.body) {
|
||||
<div class="review-body">{{ review.body }}</div>
|
||||
} @else {
|
||||
<div class="review-empty-text">No review content available</div>
|
||||
<div class="review-empty-text">{{ t('empty.noContent') }}</div>
|
||||
}
|
||||
}
|
||||
<div class="review-fade-overlay"></div>
|
||||
@@ -157,7 +158,7 @@
|
||||
[severity]="reviewsLocked ? 'danger' : 'success'"
|
||||
[disabled]="!reviewDownloadEnabled || loading"
|
||||
(click)="toggleReviewsLock()"
|
||||
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Unlock Reviews' : 'Lock Reviews'))"
|
||||
[pTooltip]="loading ? t('tooltip.pleaseWait') : (!reviewDownloadEnabled ? t('tooltip.enableDownloads') : (reviewsLocked ? t('tooltip.unlockReviews') : t('tooltip.lockReviews')))"
|
||||
tooltipPosition="left"
|
||||
class="action-btn"/>
|
||||
|
||||
@@ -169,7 +170,7 @@
|
||||
[loading]="loading"
|
||||
[disabled]="reviewsLocked || !reviewDownloadEnabled || loading"
|
||||
(click)="fetchNewReviews()"
|
||||
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (!reviewDownloadEnabled ? 'Enable review downloads in settings to use this feature' : (reviewsLocked ? 'Reviews are locked' : 'Fetch New Reviews'))"
|
||||
[pTooltip]="loading ? t('tooltip.pleaseWait') : (!reviewDownloadEnabled ? t('tooltip.enableDownloads') : (reviewsLocked ? t('tooltip.reviewsLocked') : t('tooltip.fetchNewReviews')))"
|
||||
tooltipPosition="left"
|
||||
class="action-btn"/>
|
||||
}
|
||||
@@ -182,7 +183,7 @@
|
||||
severity="danger"
|
||||
[disabled]="reviewsLocked || loading"
|
||||
(click)="deleteAllReviews()"
|
||||
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (reviewsLocked ? 'Reviews are locked' : 'Delete All Reviews')"
|
||||
[pTooltip]="loading ? t('tooltip.pleaseWait') : (reviewsLocked ? t('tooltip.reviewsLocked') : t('tooltip.deleteAllReviews'))"
|
||||
tooltipPosition="left"
|
||||
class="action-btn"/>
|
||||
}
|
||||
@@ -195,7 +196,7 @@
|
||||
[severity]="allSpoilersRevealed ? 'warn' : 'info'"
|
||||
[disabled]="loading"
|
||||
(click)="toggleSpoilerVisibility()"
|
||||
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (allSpoilersRevealed ? 'Hide All Spoilers' : 'Reveal All Spoilers')"
|
||||
[pTooltip]="loading ? t('tooltip.pleaseWait') : (allSpoilersRevealed ? t('tooltip.hideAllSpoilers') : t('tooltip.revealAllSpoilers'))"
|
||||
tooltipPosition="left"
|
||||
class="action-btn"/>
|
||||
|
||||
@@ -206,10 +207,11 @@
|
||||
severity="contrast"
|
||||
[disabled]="loading"
|
||||
(click)="toggleSortOrder()"
|
||||
[pTooltip]="loading ? 'Please wait while reviews are being fetched' : (sortAscending ? 'Sort by Newest First' : 'Sort by Oldest First')"
|
||||
[pTooltip]="loading ? t('tooltip.pleaseWait') : (sortAscending ? t('tooltip.sortNewest') : t('tooltip.sortOldest'))"
|
||||
tooltipPosition="left"
|
||||
class="action-btn"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Rating} from 'primeng/rating';
|
||||
import {Tag} from 'primeng/tag';
|
||||
import {Button} from 'primeng/button';
|
||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
import {UserService} from '../../../settings/user-management/user.service';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
@@ -16,7 +17,7 @@ import {AppSettingsService} from '../../../../shared/service/app-settings.servic
|
||||
@Component({
|
||||
selector: 'app-book-reviews',
|
||||
standalone: true,
|
||||
imports: [ProgressSpinner, Rating, Tag, Button, FormsModule, Tooltip],
|
||||
imports: [ProgressSpinner, Rating, Tag, Button, FormsModule, Tooltip, TranslocoDirective],
|
||||
templateUrl: './book-reviews.component.html',
|
||||
styleUrl: './book-reviews.component.scss'
|
||||
})
|
||||
@@ -32,6 +33,7 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
private userService = inject(UserService);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
loading = false;
|
||||
hasPermission = false;
|
||||
@@ -84,8 +86,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed to Load Reviews',
|
||||
detail: 'Could not load reviews for this book.',
|
||||
summary: this.t.translate('book.reviews.toast.loadFailedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.loadFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -107,8 +109,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
this.updateSpoilerState();
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Reviews Updated',
|
||||
detail: 'Latest reviews have been fetched successfully.',
|
||||
summary: this.t.translate('book.reviews.toast.reviewsUpdatedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.reviewsUpdatedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
@@ -117,8 +119,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
this.loading = false;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Fetch Failed',
|
||||
detail: 'Could not fetch new reviews for this book.',
|
||||
summary: this.t.translate('book.reviews.toast.fetchFailedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.fetchFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -129,8 +131,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
if (!this.reviews || this.reviews.length === 0 || this.reviewsLocked) return;
|
||||
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete all ${this.reviews.length} reviews for this book? This action cannot be undone.`,
|
||||
header: 'Confirm Delete All',
|
||||
message: this.t.translate('book.reviews.confirm.deleteAllMessage', {count: this.reviews.length}),
|
||||
header: this.t.translate('book.reviews.confirm.deleteAllHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
@@ -145,8 +147,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
this.allSpoilersRevealed = false;
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'All Reviews Deleted',
|
||||
detail: 'All reviews have been successfully deleted.',
|
||||
summary: this.t.translate('book.reviews.toast.allDeletedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.allDeletedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
@@ -154,8 +156,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
console.error('Failed to delete all reviews:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Delete Failed',
|
||||
detail: 'Could not delete all reviews.',
|
||||
summary: this.t.translate('book.reviews.toast.deleteAllFailedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.deleteAllFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -200,13 +202,10 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
const action = newLockState ? 'locked' : 'unlocked';
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: `Reviews ${action.charAt(0).toUpperCase() + action.slice(1)}`,
|
||||
detail: newLockState
|
||||
? 'Reviews are now protected from modifications and refreshes.'
|
||||
: 'Reviews can now be modified and refreshed.',
|
||||
summary: this.t.translate(newLockState ? 'book.reviews.toast.lockedSummary' : 'book.reviews.toast.unlockedSummary'),
|
||||
detail: this.t.translate(newLockState ? 'book.reviews.toast.lockedDetail' : 'book.reviews.toast.unlockedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
@@ -214,8 +213,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
console.error('Failed to toggle lock status:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Lock Toggle Failed',
|
||||
detail: 'Could not change the lock status for reviews.',
|
||||
summary: this.t.translate('book.reviews.toast.lockFailedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.lockFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -255,8 +254,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
if (!review.id || this.reviewsLocked) return;
|
||||
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete this review by ${review.reviewerName || 'Anonymous'}?`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.reviews.confirm.deleteMessage', {reviewer: review.reviewerName || 'Anonymous'}),
|
||||
header: this.t.translate('book.reviews.confirm.deleteHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
@@ -268,8 +267,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
this.updateSpoilerState();
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Review Deleted',
|
||||
detail: 'The review has been successfully deleted.',
|
||||
summary: this.t.translate('book.reviews.toast.deleteSuccessSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.deleteSuccessDetail'),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -277,8 +276,8 @@ export class BookReviewsComponent implements OnInit, OnChanges {
|
||||
console.error('Failed to delete review:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Delete Failed',
|
||||
detail: 'Could not delete the review.',
|
||||
summary: this.t.translate('book.reviews.toast.deleteFailedSummary'),
|
||||
detail: this.t.translate('book.reviews.toast.deleteFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.searcher'">
|
||||
<div class="book-searcher-container">
|
||||
<p-iconfield class="search-iconfield" [class.focused]="isSearchFocused" [class.has-results]="isDropdownOpen">
|
||||
<p-inputicon class="pi pi-search"/>
|
||||
@@ -8,7 +9,7 @@
|
||||
(input)="onSearchInputChange()"
|
||||
(focus)="isSearchFocused = true"
|
||||
(blur)="onSearchBlur()"
|
||||
placeholder="Title, Author, Series, Genre, or ISBN..."
|
||||
[placeholder]="t('placeholder')"
|
||||
class="search-input"
|
||||
/>
|
||||
@if (searchQuery) {
|
||||
@@ -18,7 +19,7 @@
|
||||
[text]="true"
|
||||
[rounded]="true"
|
||||
class="clear-search-btn"
|
||||
aria-label="Clear Search">
|
||||
[attr.aria-label]="t('clearSearch')">
|
||||
</p-button>
|
||||
}
|
||||
</p-iconfield>
|
||||
@@ -34,7 +35,7 @@
|
||||
<div class="search-item-content">
|
||||
<img
|
||||
[attr.src]="urlHelper.getThumbnailUrl(book.id, book.metadata?.coverUpdatedOn)"
|
||||
alt="Book Cover"
|
||||
[alt]="t('bookCoverAlt')"
|
||||
class="search-book-cover"
|
||||
/>
|
||||
<div class="search-book-details">
|
||||
@@ -45,7 +46,7 @@
|
||||
</div>
|
||||
<div class="search-book-meta-line">
|
||||
@if (book.metadata?.authors?.length ?? 0 > 0) {
|
||||
<p class="search-book-authors">by {{ getAuthorNames(book.metadata?.authors) }}</p>
|
||||
<p class="search-book-authors">{{ t('byPrefix') }} {{ getAuthorNames(book.metadata?.authors) }}</p>
|
||||
}
|
||||
@if (getPublishedYear(book.metadata?.publishedDate)) {
|
||||
<span class="metadata-badge year-badge">{{ getPublishedYear(book.metadata?.publishedDate) }}</span>
|
||||
@@ -64,9 +65,10 @@
|
||||
}
|
||||
} @else {
|
||||
<div class="search-dropdown-item no-results">
|
||||
<span>No results found</span>
|
||||
<span>{{ t('noResults') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {Router} from '@angular/router';
|
||||
import {IconField} from 'primeng/iconfield';
|
||||
import {InputIcon} from 'primeng/inputicon';
|
||||
import {HeaderFilter} from '../book-browser/filters/HeaderFilter';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-searcher',
|
||||
@@ -24,7 +25,8 @@ import {HeaderFilter} from '../book-browser/filters/HeaderFilter';
|
||||
SlicePipe,
|
||||
Divider,
|
||||
IconField,
|
||||
InputIcon
|
||||
InputIcon,
|
||||
TranslocoDirective,
|
||||
],
|
||||
styleUrls: ['./book-searcher.component.scss'],
|
||||
standalone: true
|
||||
@@ -39,6 +41,7 @@ export class BookSearcherComponent implements OnInit, OnDestroy {
|
||||
private bookService = inject(BookService);
|
||||
private router = inject(Router);
|
||||
protected urlHelper = inject(UrlHelperService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private headerFilter = new HeaderFilter(this.#searchSubject.asObservable());
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -56,7 +59,7 @@ export class BookSearcherComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getAuthorNames(authors: string[] | undefined): string {
|
||||
return authors?.join(', ') || 'Unknown Author';
|
||||
return authors?.join(', ') || this.t.translate('book.searcher.unknownAuthor');
|
||||
}
|
||||
|
||||
getPublishedYear(publishedDate: string | undefined): string | null {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.sender'">
|
||||
<div class="book-sender-container">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-envelope header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Send Book</h2>
|
||||
<p class="panel-description">Email this book to a recipient</p>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">{{ t('description') }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -20,11 +21,11 @@
|
||||
<div class="dialog-content">
|
||||
<div class="form-fields">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Email Provider</label>
|
||||
<label class="field-label">{{ t('emailProvider') }}</label>
|
||||
<p-select
|
||||
[options]="emailProviders"
|
||||
optionLabel="label"
|
||||
placeholder="Select Email Provider"
|
||||
[placeholder]="t('selectProvider')"
|
||||
[(ngModel)]="selectedProvider"
|
||||
appendTo="body"
|
||||
class="full-width">
|
||||
@@ -32,11 +33,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Recipient</label>
|
||||
<label class="field-label">{{ t('recipient') }}</label>
|
||||
<p-select
|
||||
[options]="emailRecipients"
|
||||
optionLabel="label"
|
||||
placeholder="Select Book Recipient"
|
||||
[placeholder]="t('selectRecipient')"
|
||||
[(ngModel)]="selectedRecipient"
|
||||
[disabled]="!selectedProvider"
|
||||
appendTo="body"
|
||||
@@ -46,14 +47,14 @@
|
||||
|
||||
@if (emailableFiles.length > 0) {
|
||||
<div class="form-field">
|
||||
<label class="field-label">File Format</label>
|
||||
<label class="field-label">{{ t('fileFormat') }}</label>
|
||||
<div class="format-options">
|
||||
@for (file of emailableFiles; track file.id) {
|
||||
<div class="format-option" [class.selected]="selectedFileId === file.id">
|
||||
<p-radioButton name="fileFormat" [value]="file.id" [(ngModel)]="selectedFileId"/>
|
||||
<div class="format-info">
|
||||
<span class="format-type">{{ file.bookType || 'Unknown' }}</span>
|
||||
@if (file.isPrimary) { <span class="primary-badge">Primary</span> }
|
||||
<span class="format-type">{{ file.bookType || t('unknownFormat') }}</span>
|
||||
@if (file.isPrimary) { <span class="primary-badge">{{ t('primaryBadge') }}</span> }
|
||||
<span class="file-size">{{ formatFileSize(file.fileSizeKb) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +66,7 @@
|
||||
@if (showLargeFileWarning) {
|
||||
<div class="warning-banner">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>This file exceeds 25MB. Some email providers may reject large attachments.</span>
|
||||
<span>{{ t('largeFileWarning') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -74,9 +75,10 @@
|
||||
<div class="dialog-footer">
|
||||
<p-button
|
||||
icon="pi pi-envelope"
|
||||
label="Send Book"
|
||||
[label]="t('sendBook')"
|
||||
[disabled]="!selectedProvider || !selectedRecipient"
|
||||
(onClick)="sendBook()">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {EmailV2ProviderService} from '../../../settings/email-v2/email-v2-provid
|
||||
import {EmailV2RecipientService} from '../../../settings/email-v2/email-v2-recipient/email-v2-recipient.service';
|
||||
import {Book, BookFile} from '../../model/book.model';
|
||||
import {RadioButton} from 'primeng/radiobutton';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
interface EmailableFile {
|
||||
id: number;
|
||||
@@ -27,7 +28,8 @@ const LARGE_FILE_THRESHOLD_KB = 25 * 1024; // 25MB
|
||||
Button,
|
||||
Select,
|
||||
FormsModule,
|
||||
RadioButton
|
||||
RadioButton,
|
||||
TranslocoDirective,
|
||||
],
|
||||
templateUrl: './book-sender.component.html',
|
||||
styleUrls: ['./book-sender.component.scss']
|
||||
@@ -38,6 +40,7 @@ export class BookSenderComponent implements OnInit {
|
||||
private emailRecipientService = inject(EmailV2RecipientService);
|
||||
private emailService = inject(EmailService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
dynamicDialogRef = inject(DynamicDialogRef);
|
||||
private dynamicDialogConfig = inject(DynamicDialogConfig);
|
||||
|
||||
@@ -143,16 +146,16 @@ export class BookSenderComponent implements OnInit {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Email Scheduled',
|
||||
detail: 'The book has been successfully scheduled for sending.'
|
||||
summary: this.t.translate('book.sender.toast.emailScheduledSummary'),
|
||||
detail: this.t.translate('book.sender.toast.emailScheduledDetail')
|
||||
});
|
||||
this.dynamicDialogRef.close(true);
|
||||
},
|
||||
error: (error) => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Sending Failed',
|
||||
detail: 'There was an issue while scheduling the book for sending. Please try again later.'
|
||||
summary: this.t.translate('book.sender.toast.sendingFailedSummary'),
|
||||
detail: this.t.translate('book.sender.toast.sendingFailedDetail')
|
||||
});
|
||||
console.error('Error sending book:', error);
|
||||
}
|
||||
@@ -161,22 +164,22 @@ export class BookSenderComponent implements OnInit {
|
||||
if (!this.selectedProvider) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Email Provider Missing',
|
||||
detail: 'Please select an email provider to proceed.'
|
||||
summary: this.t.translate('book.sender.toast.providerMissingSummary'),
|
||||
detail: this.t.translate('book.sender.toast.providerMissingDetail')
|
||||
});
|
||||
}
|
||||
if (!this.selectedRecipient) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Recipient Missing',
|
||||
detail: 'Please select a recipient to send the book.'
|
||||
summary: this.t.translate('book.sender.toast.recipientMissingSummary'),
|
||||
detail: this.t.translate('book.sender.toast.recipientMissingDetail')
|
||||
});
|
||||
}
|
||||
if (!this.book?.id) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Book Not Selected',
|
||||
detail: 'Please select a book to send.'
|
||||
summary: this.t.translate('book.sender.toast.bookNotSelectedSummary'),
|
||||
detail: this.t.translate('book.sender.toast.bookNotSelectedDetail')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.seriesPage'">
|
||||
@if (filteredBooks$ | async; as books) {
|
||||
<div class="series-wrapper">
|
||||
<p-tabs [value]="tab" lazy="true" scrollable>
|
||||
<p-tablist>
|
||||
<p-tab value="view">
|
||||
<i [class]="'pi pi-crown'"></i>
|
||||
Series Details
|
||||
{{ t('seriesDetailsTab') }}
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
<p-tabpanels class="tabpanels-responsive">
|
||||
@@ -49,7 +50,7 @@
|
||||
<div class="metadata-section-padding">
|
||||
<div class="metadata-grid">
|
||||
<p>
|
||||
<span class="metadata-label">Publisher: </span>
|
||||
<span class="metadata-label">{{ t('publisher') }} </span>
|
||||
@if (firstBook.metadata?.publisher; as publisher) {
|
||||
<span class="publisher-link" (click)="goToPublisher(publisher)">
|
||||
{{publisher}}
|
||||
@@ -58,10 +59,10 @@
|
||||
<span>-</span>
|
||||
}
|
||||
</p>
|
||||
<p><strong>Years:</strong> {{ (yearsRange$ | async) || '-' }}</p>
|
||||
<p><strong>Number of books:</strong> {{ books.length || 0}}</p>
|
||||
<p><strong>Language:</strong> {{ firstBook.metadata?.language || "-"}}</p>
|
||||
<p><strong>Read Status: </strong>
|
||||
<p><strong>{{ t('years') }}</strong> {{ (yearsRange$ | async) || '-' }}</p>
|
||||
<p><strong>{{ t('numberOfBooks') }}</strong> {{ books.length || 0}}</p>
|
||||
<p><strong>{{ t('language') }}</strong> {{ firstBook.metadata?.language || "-"}}</p>
|
||||
<p><strong>{{ t('readStatus') }}</strong>
|
||||
@let s = seriesReadStatus$ | async;
|
||||
<span class="status-badge"
|
||||
[ngClass]="getStatusSeverityClass(s || 'UNREAD')">
|
||||
@@ -79,11 +80,11 @@
|
||||
<div [ngClass]="{ 'line-clamp-5': !isExpanded, 'line-clamp-none': isExpanded }"
|
||||
class="description-container">
|
||||
<div class="readonly-editor"
|
||||
[innerHTML]="(firstDescription$ | async) || 'No description available.'"></div>
|
||||
[innerHTML]="(firstDescription$ | async) || t('noDescription')"></div>
|
||||
</div>
|
||||
@let desc = firstDescription$ | async;
|
||||
@if ((desc?.length ?? 0) > 500) {
|
||||
<p-button [label]="isExpanded ? 'Show less' : 'Show more'"
|
||||
<p-button [label]="isExpanded ? t('showLess') : t('showMore')"
|
||||
[icon]="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" iconPos="right" size="small" text
|
||||
(click)="toggleExpand()">
|
||||
</p-button>
|
||||
@@ -108,7 +109,7 @@
|
||||
</div>
|
||||
}
|
||||
@if (books.length === 0) {
|
||||
<div class="empty-state">No books found for this series.</div>
|
||||
<div class="empty-state">{{ t('noBooksFound') }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -129,7 +130,7 @@
|
||||
<div class="selected-count-badge">
|
||||
<i class="pi pi-check-circle badge-icon"></i>
|
||||
<span class="badge-count" style="color: var(--primary-color)">{{ selectedBooks.size }}</span>
|
||||
<span class="badge-label">selected</span>
|
||||
<span class="badge-label">{{ t('selected') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-center">
|
||||
@@ -138,7 +139,7 @@
|
||||
<p-tieredMenu #menu [model]="metadataMenuItems" [popup]="true" appendTo="body"/>
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="Metadata actions"
|
||||
[pTooltip]="t('tooltip.metadataActions')"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
@@ -150,7 +151,7 @@
|
||||
outlined="true"
|
||||
severity="info"
|
||||
(onClick)="openShelfAssigner()"
|
||||
pTooltip="Assign to shelf"
|
||||
[pTooltip]="t('tooltip.assignToShelf')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
@if (userState.user!.permissions.canBulkLockUnlockMetadata) {
|
||||
@@ -159,7 +160,7 @@
|
||||
icon="pi pi-lock"
|
||||
severity="info"
|
||||
(click)="lockUnlockMetadata()"
|
||||
pTooltip="Lock/Unlock metadata"
|
||||
[pTooltip]="t('tooltip.lockUnlockMetadata')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -169,7 +170,7 @@
|
||||
icon="pi pi-arrows-h"
|
||||
severity="info"
|
||||
(click)="moveFiles()"
|
||||
pTooltip="Organize Files"
|
||||
[pTooltip]="t('tooltip.organizeFiles')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -177,7 +178,7 @@
|
||||
<div class="more-actions-wrapper">
|
||||
<p-button
|
||||
(click)="menu.toggle($event)"
|
||||
pTooltip="More actions"
|
||||
[pTooltip]="t('tooltip.moreActions')"
|
||||
tooltipPosition="top"
|
||||
outlined="true"
|
||||
severity="info"
|
||||
@@ -194,7 +195,7 @@
|
||||
icon="pi pi-check-square"
|
||||
severity="success"
|
||||
(click)="selectAllBooks()"
|
||||
pTooltip="Select all books"
|
||||
[pTooltip]="t('tooltip.selectAll')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
<p-button
|
||||
@@ -202,7 +203,7 @@
|
||||
icon="pi pi-times"
|
||||
severity="warn"
|
||||
(click)="deselectAllBooks()"
|
||||
pTooltip="Deselect all books"
|
||||
[pTooltip]="t('tooltip.deselectAll')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
</div>
|
||||
@@ -213,7 +214,7 @@
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
(click)="confirmDeleteBooks()"
|
||||
pTooltip="Delete selected books"
|
||||
[pTooltip]="t('tooltip.deleteSelected')"
|
||||
tooltipPosition="top">
|
||||
</p-button>
|
||||
}
|
||||
@@ -230,7 +231,8 @@
|
||||
<p-progressSpinner strokeWidth="4" fill="transparent" animationDuration=".8s">
|
||||
</p-progressSpinner>
|
||||
<p style="color: var(--primary-color);">
|
||||
Loading series details...
|
||||
{{ t('loadingSeriesDetails') }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {TaskHelperService} from "../../../settings/task-management/task-helper.s
|
||||
import {MetadataRefreshType} from "../../../metadata/model/request/metadata-refresh-type.enum";
|
||||
import {TieredMenu} from "primeng/tieredmenu";
|
||||
import {AppSettingsService} from "../../../../shared/service/app-settings.service";
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
import {Tooltip} from "primeng/tooltip";
|
||||
import {Divider} from "primeng/divider";
|
||||
import {animate, style, transition, trigger} from "@angular/animations";
|
||||
@@ -50,7 +51,8 @@ import {BookCardOverlayPreferenceService} from '../book-browser/book-card-overla
|
||||
VirtualScrollerModule,
|
||||
TieredMenu,
|
||||
Tooltip,
|
||||
Divider
|
||||
Divider,
|
||||
TranslocoDirective
|
||||
],
|
||||
animations: [
|
||||
trigger('slideInOut', [
|
||||
@@ -82,6 +84,7 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
private messageService = inject(MessageService);
|
||||
protected bookCardOverlayPreferenceService = inject(BookCardOverlayPreferenceService);
|
||||
protected appSettingsService = inject(AppSettingsService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
tab: string = "view";
|
||||
isExpanded = false;
|
||||
@@ -271,23 +274,23 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
const v = (value ?? '').toString().toUpperCase();
|
||||
switch (v) {
|
||||
case ReadStatus.UNREAD:
|
||||
return 'UNREAD';
|
||||
return this.t.translate('book.seriesPage.status.unread');
|
||||
case ReadStatus.READING:
|
||||
return 'READING';
|
||||
return this.t.translate('book.seriesPage.status.reading');
|
||||
case ReadStatus.RE_READING:
|
||||
return 'RE-READING';
|
||||
return this.t.translate('book.seriesPage.status.reReading');
|
||||
case ReadStatus.READ:
|
||||
return 'READ';
|
||||
return this.t.translate('book.seriesPage.status.read');
|
||||
case ReadStatus.PARTIALLY_READ:
|
||||
return 'PARTIALLY READ';
|
||||
return this.t.translate('book.seriesPage.status.partiallyRead');
|
||||
case ReadStatus.PAUSED:
|
||||
return 'PAUSED';
|
||||
return this.t.translate('book.seriesPage.status.paused');
|
||||
case ReadStatus.ABANDONED:
|
||||
return 'ABANDONED';
|
||||
return this.t.translate('book.seriesPage.status.abandoned');
|
||||
case ReadStatus.WONT_READ:
|
||||
return "WON'T READ";
|
||||
return this.t.translate('book.seriesPage.status.wontRead');
|
||||
default:
|
||||
return 'UNSET';
|
||||
return this.t.translate('book.seriesPage.status.unset');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,18 +377,18 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
|
||||
confirmDeleteBooks(): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?\n\nThis will permanently remove the book files from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
message: this.t.translate('book.browser.confirm.deleteMessage', {count: this.selectedBooks.size}),
|
||||
header: this.t.translate('book.browser.confirm.deleteHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptLabel: this.t.translate('common.delete'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
const count = this.selectedBooks.size;
|
||||
const loader = this.loadingService.show(`Deleting ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.browser.loading.deleting', {count}));
|
||||
|
||||
this.bookService.deleteBooks(this.selectedBooks)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
@@ -437,17 +440,17 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
if (!this.selectedBooks || this.selectedBooks.size === 0) return;
|
||||
const count = this.selectedBooks.size;
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to regenerate covers for ${count} book(s)?`,
|
||||
header: 'Confirm Cover Regeneration',
|
||||
message: this.t.translate('book.browser.confirm.regenCoverMessage', {count}),
|
||||
header: this.t.translate('book.browser.confirm.regenCoverHeader'),
|
||||
icon: 'pi pi-image',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'success'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'No',
|
||||
label: this.t.translate('common.no'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
@@ -455,16 +458,16 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Cover Regeneration Started',
|
||||
detail: `Regenerating covers for ${count} book(s). Refresh the page when complete.`,
|
||||
summary: this.t.translate('book.browser.toast.regenCoverStartedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.regenCoverStartedDetail', {count}),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not start cover regeneration.',
|
||||
summary: this.t.translate('book.browser.toast.failedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.regenCoverFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -477,17 +480,17 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
if (!this.selectedBooks || this.selectedBooks.size === 0) return;
|
||||
const count = this.selectedBooks.size;
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to generate custom covers for ${count} book(s)?`,
|
||||
header: 'Confirm Custom Cover Generation',
|
||||
message: this.t.translate('book.browser.confirm.customCoverMessage', {count}),
|
||||
header: this.t.translate('book.browser.confirm.customCoverHeader'),
|
||||
icon: 'pi pi-palette',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'success'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'No',
|
||||
label: this.t.translate('common.no'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
@@ -495,16 +498,16 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Custom Cover Generation Started',
|
||||
detail: `Generating custom covers for ${count} book(s).`,
|
||||
summary: this.t.translate('book.browser.toast.customCoverStartedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.customCoverStartedDetail', {count}),
|
||||
life: 3000
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Could not start custom cover generation.',
|
||||
summary: this.t.translate('book.browser.toast.failedSummary'),
|
||||
detail: this.t.translate('book.browser.toast.customCoverFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.shelfAssigner'">
|
||||
<div class="shelf-assigner">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-bookmark header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Assign Books to Shelves</h2>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">
|
||||
@if (isMultiBooks) {
|
||||
Select shelves for {{ bookIds.size }} {{ bookIds.size === 1 ? 'book' : 'books' }}
|
||||
{{ t('descriptionMulti', { count: bookIds.size }) }}
|
||||
} @else {
|
||||
Organize "{{ book.metadata?.title }}" into your shelves
|
||||
{{ t('descriptionSingle', { title: book.metadata?.title }) }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -29,7 +30,7 @@
|
||||
<div class="list-header">
|
||||
<span class="shelf-count">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
{{ (shelfState$ | async)!.shelves!.length }} {{ (shelfState$ | async)!.shelves!.length === 1 ? 'shelf' : 'shelves' }} available
|
||||
{{ t('shelvesAvailable', { count: (shelfState$ | async)!.shelves!.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -68,18 +69,15 @@
|
||||
<div class="empty-icon-wrapper">
|
||||
<i class="pi pi-bookmark empty-icon"></i>
|
||||
</div>
|
||||
<h3 class="empty-title">No Shelves Available</h3>
|
||||
<p class="empty-description">
|
||||
Create your first shelf to start organizing your books.<br/>
|
||||
Click the button below to get started.
|
||||
</p>
|
||||
<h3 class="empty-title">{{ t('emptyTitle') }}</h3>
|
||||
<p class="empty-description" [innerHTML]="t('emptyDescription')"></p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<p-button
|
||||
label="Create Shelf"
|
||||
[label]="t('createShelf')"
|
||||
icon="pi pi-plus"
|
||||
[outlined]="true"
|
||||
severity="info"
|
||||
@@ -87,13 +85,13 @@
|
||||
/>
|
||||
<div class="footer-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
[label]="t('cancelButton')"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
(onClick)="closeDialog()"
|
||||
/>
|
||||
<p-button
|
||||
label="Save Changes"
|
||||
[label]="t('saveChanges')"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
(onClick)="updateBooksShelves()"
|
||||
@@ -101,3 +99,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {LoadingService} from '../../../../core/services/loading.service';
|
||||
import {UserService} from '../../../settings/user-management/user.service';
|
||||
import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component';
|
||||
import {IconSelection} from '../../../../shared/service/icon-picker.service';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shelf-assigner',
|
||||
@@ -28,7 +29,8 @@ import {IconSelection} from '../../../../shared/service/icon-picker.service';
|
||||
Checkbox,
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
IconDisplayComponent
|
||||
IconDisplayComponent,
|
||||
TranslocoDirective
|
||||
]
|
||||
})
|
||||
export class ShelfAssignerComponent implements OnInit {
|
||||
@@ -41,6 +43,7 @@ export class ShelfAssignerComponent implements OnInit {
|
||||
private bookDialogHelper = inject(BookDialogHelperService);
|
||||
private loadingService = inject(LoadingService);
|
||||
private userService = inject(UserService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
shelfState$: Observable<ShelfState> = combineLatest([
|
||||
this.shelfService.shelfState$,
|
||||
@@ -78,17 +81,17 @@ export class ShelfAssignerComponent implements OnInit {
|
||||
}
|
||||
|
||||
private updateBookShelves(bookIds: Set<number>, idsToAssign: Set<number | undefined>, idsToUnassign: Set<number>): void {
|
||||
const loader = this.loadingService.show(`Updating shelves for ${bookIds.size} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.shelfAssigner.loading.updatingShelves', { count: bookIds.size }));
|
||||
|
||||
this.bookService.updateBookShelves(bookIds, idsToAssign, idsToUnassign)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book shelves updated'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfAssigner.toast.updateSuccessDetail')});
|
||||
this.dynamicDialogRef.close({assigned: true});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update book shelves'});
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('book.shelfAssigner.toast.updateFailedDetail')});
|
||||
this.dynamicDialogRef.close({assigned: false});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.shelfCreator'">
|
||||
<div class="shelf-creator">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-bookmark header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Create New Shelf</h2>
|
||||
<p class="panel-description">Add a custom shelf to organize your books</p>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">{{ t('description') }}</p>
|
||||
</div>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
@@ -14,7 +15,7 @@
|
||||
[text]="true"
|
||||
[rounded]="true"
|
||||
class="close-button"
|
||||
pTooltip="Close"
|
||||
[pTooltip]="t('closeTooltip')"
|
||||
tooltipPosition="left"/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +23,7 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label for="shelfName" class="form-label">
|
||||
<i class="pi pi-tag label-icon"></i>
|
||||
Shelf Name
|
||||
{{ t('shelfNameLabel') }}
|
||||
<span class="required-indicator">*</span>
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
@@ -32,9 +33,9 @@
|
||||
pInputText
|
||||
[(ngModel)]="shelfName"
|
||||
class="input-full"
|
||||
placeholder="e.g., Favorites, To Read, Currently Reading"
|
||||
[placeholder]="t('shelfNamePlaceholder')"
|
||||
[class.filled]="shelfName.trim()"
|
||||
|
||||
|
||||
/>
|
||||
@if (shelfName.trim()) {
|
||||
<i class="pi pi-check-circle input-icon success"></i>
|
||||
@@ -47,14 +48,14 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label class="form-label">
|
||||
<i class="pi pi-palette label-icon"></i>
|
||||
Shelf Icon (Optional)
|
||||
{{ t('shelfIconLabel') }}
|
||||
</label>
|
||||
@if (!selectedIcon) {
|
||||
<button class="icon-select-btn" (click)="openIconPicker()" type="button">
|
||||
<i class="pi pi-plus"></i>
|
||||
<div class="btn-content">
|
||||
<span class="btn-title">Choose an Icon</span>
|
||||
<span class="btn-subtitle">Select from available icons</span>
|
||||
<span class="btn-title">{{ t('chooseIcon') }}</span>
|
||||
<span class="btn-subtitle">{{ t('chooseIconSubtitle') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
@@ -66,7 +67,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="icon-info">
|
||||
<span class="icon-label">Selected Icon</span>
|
||||
<span class="icon-label">{{ t('selectedIcon') }}</span>
|
||||
<span class="icon-name">
|
||||
@if (selectedIcon.type === 'PRIME_NG') {
|
||||
{{ selectedIcon.value }}
|
||||
@@ -82,7 +83,7 @@
|
||||
[rounded]="true"
|
||||
size="small"
|
||||
(onClick)="clearSelectedIcon()"
|
||||
pTooltip="Remove icon"
|
||||
[pTooltip]="t('removeIconTooltip')"
|
||||
tooltipPosition="left"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,11 +96,11 @@
|
||||
<div class="form-group highlight-group">
|
||||
<label class="form-label">
|
||||
<i class="pi pi-globe label-icon"></i>
|
||||
Visibility
|
||||
{{ t('visibilityLabel') }}
|
||||
</label>
|
||||
<div class="input-wrapper" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<p-checkbox [(ngModel)]="isPublic" [binary]="true" inputId="publicShelf"></p-checkbox>
|
||||
<label for="publicShelf" style="cursor: pointer">Make this shelf public (read-only for others)</label>
|
||||
<label for="publicShelf" style="cursor: pointer">{{ t('makePublicLabel') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -110,24 +111,24 @@
|
||||
@if (!shelfName.trim()) {
|
||||
<div class="validation-message error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>Shelf name is required</span>
|
||||
<span>{{ t('validationRequired') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="validation-message success">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span>Ready to create</span>
|
||||
<span>{{ t('validationReady') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
[label]="t('cancelButton')"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
(onClick)="cancel()"
|
||||
/>
|
||||
<p-button
|
||||
label="Create Shelf"
|
||||
[label]="t('createButton')"
|
||||
icon="pi pi-plus"
|
||||
severity="success"
|
||||
(onClick)="createShelf()"
|
||||
@@ -136,3 +137,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Tooltip} from 'primeng/tooltip';
|
||||
import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component';
|
||||
import {UserService} from '../../../settings/user-management/user.service';
|
||||
import {CheckboxModule} from 'primeng/checkbox';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shelf-creator',
|
||||
@@ -22,7 +23,8 @@ import {CheckboxModule} from 'primeng/checkbox';
|
||||
InputText,
|
||||
Tooltip,
|
||||
IconDisplayComponent,
|
||||
CheckboxModule
|
||||
CheckboxModule,
|
||||
TranslocoDirective
|
||||
],
|
||||
styleUrl: './shelf-creator.component.scss',
|
||||
})
|
||||
@@ -32,6 +34,7 @@ export class ShelfCreatorComponent {
|
||||
private messageService = inject(MessageService);
|
||||
private iconPickerService = inject(IconPickerService);
|
||||
private userService = inject(UserService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
shelfName: string = '';
|
||||
selectedIcon: IconSelection | null = null;
|
||||
@@ -67,11 +70,11 @@ export class ShelfCreatorComponent {
|
||||
|
||||
this.shelfService.createShelf(newShelf as Shelf).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: `Shelf created: ${this.shelfName}`});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfCreator.toast.createSuccessDetail', { name: this.shelfName })});
|
||||
this.dynamicDialogRef.close(true);
|
||||
},
|
||||
error: (e) => {
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to create shelf'});
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('book.shelfCreator.toast.createFailedDetail')});
|
||||
console.error('Error creating shelf:', e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<ng-container *transloco="let t; prefix: 'book.shelfEditDialog'">
|
||||
<div class="shelf-form">
|
||||
<div class="panel-header">
|
||||
<div class="header-icon-wrapper">
|
||||
<i class="pi pi-pencil header-icon"></i>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h2 class="panel-title">Edit Shelf</h2>
|
||||
<h2 class="panel-title">{{ t('title') }}</h2>
|
||||
<p class="panel-description">
|
||||
Customize your shelf name and icon
|
||||
{{ t('description') }}
|
||||
</p>
|
||||
</div>
|
||||
<p-button
|
||||
@@ -22,15 +23,15 @@
|
||||
<div class="shelf-container">
|
||||
<div class="shelf-fields">
|
||||
<div class="shelf-row">
|
||||
<label class="label">Shelf Name:</label>
|
||||
<input type="text" pInputText [(ngModel)]="shelfName" placeholder="Enter shelf name..." class="input"/>
|
||||
<label class="label">{{ t('shelfNameLabel') }}</label>
|
||||
<input type="text" pInputText [(ngModel)]="shelfName" [placeholder]="t('shelfNamePlaceholder')" class="input"/>
|
||||
</div>
|
||||
|
||||
<div class="shelf-row">
|
||||
<label class="label">Shelf Icon:</label>
|
||||
<label class="label">{{ t('shelfIconLabel') }}</label>
|
||||
<div class="icon-section">
|
||||
@if (!selectedIcon) {
|
||||
<p-button label="Select Icon" icon="pi pi-search" [outlined]="true" severity="info" (onClick)="openIconPicker()"></p-button>
|
||||
<p-button [label]="t('selectIcon')" icon="pi pi-search" [outlined]="true" severity="info" (onClick)="openIconPicker()"></p-button>
|
||||
}
|
||||
|
||||
@if (selectedIcon) {
|
||||
@@ -50,10 +51,10 @@
|
||||
|
||||
@if (isAdmin) {
|
||||
<div class="shelf-row">
|
||||
<label class="label">Visibility:</label>
|
||||
<label class="label">{{ t('visibilityLabel') }}</label>
|
||||
<div class="visibility-section" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<p-checkbox [(ngModel)]="isPublic" [binary]="true" inputId="publicShelf"></p-checkbox>
|
||||
<label for="publicShelf" style="cursor: pointer; user-select: none;">Public Shelf</label>
|
||||
<label for="publicShelf" style="cursor: pointer; user-select: none;">{{ t('publicShelfLabel') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -64,13 +65,13 @@
|
||||
<div></div>
|
||||
<div class="footer-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
[label]="t('cancelButton')"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
(onClick)="closeDialog()"
|
||||
/>
|
||||
<p-button
|
||||
label="Save Changes"
|
||||
[label]="t('saveChanges')"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
(onClick)="save()"
|
||||
@@ -78,3 +79,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {IconPickerService, IconSelection} from '../../../../shared/service/icon-
|
||||
import {IconDisplayComponent} from '../../../../shared/components/icon-display/icon-display.component';
|
||||
import {CheckboxModule} from 'primeng/checkbox';
|
||||
import {UserService} from '../../../settings/user-management/user.service';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shelf-edit-dialog',
|
||||
@@ -20,7 +21,8 @@ import {UserService} from '../../../settings/user-management/user.service';
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
IconDisplayComponent,
|
||||
CheckboxModule
|
||||
CheckboxModule,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './shelf-edit-dialog.component.html',
|
||||
standalone: true,
|
||||
@@ -34,6 +36,7 @@ export class ShelfEditDialogComponent implements OnInit {
|
||||
private messageService = inject(MessageService);
|
||||
private iconPickerService = inject(IconPickerService);
|
||||
private userService = inject(UserService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
shelfName: string = '';
|
||||
selectedIcon: IconSelection | null = null;
|
||||
@@ -82,11 +85,11 @@ export class ShelfEditDialogComponent implements OnInit {
|
||||
|
||||
this.shelfService.updateShelf(shelf, this.shelf?.id).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({severity: 'success', summary: 'Shelf Updated', detail: 'The shelf was updated successfully.'});
|
||||
this.messageService.add({severity: 'success', summary: this.t.translate('book.shelfEditDialog.toast.updateSuccessSummary'), detail: this.t.translate('book.shelfEditDialog.toast.updateSuccessDetail')});
|
||||
this.dynamicDialogRef.close();
|
||||
},
|
||||
error: (e) => {
|
||||
this.messageService.add({severity: 'error', summary: 'Update Failed', detail: 'An error occurred while updating the shelf. Please try again.'});
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('book.shelfEditDialog.toast.updateFailedSummary'), detail: this.t.translate('book.shelfEditDialog.toast.updateFailedDetail')});
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {LoadingService} from '../../../core/services/loading.service';
|
||||
import {User} from '../../settings/user-management/user.service';
|
||||
import {APIException} from '../../../shared/models/api-exception.model';
|
||||
import {HttpErrorResponse} from '@angular/common/http';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -19,6 +20,7 @@ export class BookMenuService {
|
||||
messageService = inject(MessageService);
|
||||
bookService = inject(BookService);
|
||||
loadingService = inject(LoadingService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
getMetadataMenuItems(
|
||||
autoFetchMetadata: () => void,
|
||||
@@ -34,7 +36,7 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkAutoFetchMetadata) {
|
||||
items.push({
|
||||
label: 'Auto Fetch Metadata',
|
||||
label: this.t.translate('book.menuService.menu.autoFetchMetadata'),
|
||||
icon: 'pi pi-bolt',
|
||||
command: autoFetchMetadata
|
||||
});
|
||||
@@ -42,7 +44,7 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkCustomFetchMetadata) {
|
||||
items.push({
|
||||
label: 'Custom Fetch Metadata',
|
||||
label: this.t.translate('book.menuService.menu.customFetchMetadata'),
|
||||
icon: 'pi pi-sync',
|
||||
command: fetchMetadata
|
||||
});
|
||||
@@ -50,12 +52,12 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkEditMetadata) {
|
||||
items.push({
|
||||
label: 'Bulk Metadata Editor',
|
||||
label: this.t.translate('book.menuService.menu.bulkMetadataEditor'),
|
||||
icon: 'pi pi-table',
|
||||
command: bulkEditMetadata
|
||||
});
|
||||
items.push({
|
||||
label: 'Multi-Book Metadata Editor',
|
||||
label: this.t.translate('book.menuService.menu.multiBookMetadataEditor'),
|
||||
icon: 'pi pi-clone',
|
||||
command: multiBookEditMetadata
|
||||
});
|
||||
@@ -63,12 +65,12 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkRegenerateCover) {
|
||||
items.push({
|
||||
label: 'Regenerate Covers',
|
||||
label: this.t.translate('book.menuService.menu.regenerateCovers'),
|
||||
icon: 'pi pi-image',
|
||||
command: regenerateCovers
|
||||
});
|
||||
items.push({
|
||||
label: 'Generate Custom Covers',
|
||||
label: this.t.translate('book.menuService.menu.generateCustomCovers'),
|
||||
icon: 'pi pi-palette',
|
||||
command: generateCustomCovers
|
||||
});
|
||||
@@ -84,27 +86,27 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkResetBookReadStatus) {
|
||||
items.push({
|
||||
label: 'Update Read Status',
|
||||
label: this.t.translate('book.menuService.menu.updateReadStatus'),
|
||||
icon: 'pi pi-book',
|
||||
items: Object.entries(readStatusLabels).map(([status, label]) => ({
|
||||
label,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to mark ${count} book(s) as "${label}"?`,
|
||||
header: 'Confirm Read Status Update',
|
||||
message: this.t.translate('book.menuService.confirm.readStatusMessage', {count, label}),
|
||||
header: this.t.translate('book.menuService.confirm.readStatusHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'success'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'No',
|
||||
label: this.t.translate('common.no'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Updating read status for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.updatingReadStatus', {count}));
|
||||
|
||||
this.bookService.updateBookReadStatus(Array.from(selectedBooks), status as ReadStatus)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
@@ -112,8 +114,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Read Status Updated',
|
||||
detail: `Marked as "${label}"`,
|
||||
summary: this.t.translate('book.menuService.toast.readStatusUpdatedSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.readStatusUpdatedDetail', {label}),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -121,8 +123,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: apiError?.message || 'Could not update read status.',
|
||||
summary: this.t.translate('book.menuService.toast.updateFailedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.readStatusFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -136,20 +138,20 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkEditMetadata) {
|
||||
items.push({
|
||||
label: 'Set Age Rating',
|
||||
label: this.t.translate('book.menuService.menu.setAgeRating'),
|
||||
icon: 'pi pi-user',
|
||||
items: [
|
||||
...AGE_RATING_OPTIONS.map(option => ({
|
||||
label: option.label,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to set the age rating to "${option.label}" for ${count} book(s)?`,
|
||||
header: 'Confirm Age Rating Update',
|
||||
message: this.t.translate('book.menuService.confirm.ageRatingMessage', {label: option.label, count}),
|
||||
header: this.t.translate('book.menuService.confirm.ageRatingHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Setting age rating for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.settingAgeRating', {count}));
|
||||
this.bookService.updateBooksMetadata({
|
||||
bookIds: Array.from(selectedBooks),
|
||||
ageRating: option.id
|
||||
@@ -158,8 +160,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Age Rating Updated',
|
||||
detail: `Set to "${option.label}"`,
|
||||
summary: this.t.translate('book.menuService.toast.ageRatingUpdatedSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.ageRatingUpdatedDetail', {label: option.label}),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -167,8 +169,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: apiError?.message || 'Could not update age rating.',
|
||||
summary: this.t.translate('book.menuService.toast.updateFailedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.ageRatingFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -181,17 +183,17 @@ export class BookMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Clear Age Rating',
|
||||
label: this.t.translate('book.menuService.menu.clearAgeRating'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to clear the age rating for ${count} book(s)?`,
|
||||
header: 'Confirm Clear Age Rating',
|
||||
message: this.t.translate('book.menuService.confirm.clearAgeRatingMessage', {count}),
|
||||
header: this.t.translate('book.menuService.confirm.clearAgeRatingHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Clearing age rating for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.clearingAgeRating', {count}));
|
||||
this.bookService.updateBooksMetadata({
|
||||
bookIds: Array.from(selectedBooks),
|
||||
clearAgeRating: true
|
||||
@@ -200,8 +202,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Age Rating Cleared',
|
||||
detail: 'Age rating has been cleared.',
|
||||
summary: this.t.translate('book.menuService.toast.ageRatingClearedSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.ageRatingClearedDetail'),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -209,8 +211,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: apiError?.message || 'Could not clear age rating.',
|
||||
summary: this.t.translate('book.menuService.toast.updateFailedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.clearAgeRatingFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -223,20 +225,20 @@ export class BookMenuService {
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Set Content Rating',
|
||||
label: this.t.translate('book.menuService.menu.setContentRating'),
|
||||
icon: 'pi pi-shield',
|
||||
items: [
|
||||
...Object.entries(CONTENT_RATING_LABELS).map(([value, label]) => ({
|
||||
label: label,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to set the content rating to "${label}" for ${count} book(s)?`,
|
||||
header: 'Confirm Content Rating Update',
|
||||
message: this.t.translate('book.menuService.confirm.contentRatingMessage', {label, count}),
|
||||
header: this.t.translate('book.menuService.confirm.contentRatingHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Setting content rating for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.settingContentRating', {count}));
|
||||
this.bookService.updateBooksMetadata({
|
||||
bookIds: Array.from(selectedBooks),
|
||||
contentRating: value
|
||||
@@ -245,8 +247,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Content Rating Updated',
|
||||
detail: `Set to "${label}"`,
|
||||
summary: this.t.translate('book.menuService.toast.contentRatingUpdatedSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.contentRatingUpdatedDetail', {label}),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -254,8 +256,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: apiError?.message || 'Could not update content rating.',
|
||||
summary: this.t.translate('book.menuService.toast.updateFailedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.contentRatingFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -268,17 +270,17 @@ export class BookMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Clear Content Rating',
|
||||
label: this.t.translate('book.menuService.menu.clearContentRating'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to clear the content rating for ${count} book(s)?`,
|
||||
header: 'Confirm Clear Content Rating',
|
||||
message: this.t.translate('book.menuService.confirm.clearContentRatingMessage', {count}),
|
||||
header: this.t.translate('book.menuService.confirm.clearContentRatingHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Clearing content rating for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.clearingContentRating', {count}));
|
||||
this.bookService.updateBooksMetadata({
|
||||
bookIds: Array.from(selectedBooks),
|
||||
clearContentRating: true
|
||||
@@ -287,8 +289,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Content Rating Cleared',
|
||||
detail: 'Content rating has been cleared.',
|
||||
summary: this.t.translate('book.menuService.toast.contentRatingClearedSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.contentRatingClearedDetail'),
|
||||
life: 2000
|
||||
});
|
||||
},
|
||||
@@ -296,8 +298,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: apiError?.message || 'Could not clear content rating.',
|
||||
summary: this.t.translate('book.menuService.toast.updateFailedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.clearContentRatingFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -313,17 +315,17 @@ export class BookMenuService {
|
||||
// Shelf Actions
|
||||
if (permissions?.canManageLibrary || permissions?.admin) { // Assuming these permissions cover shelf management for books
|
||||
items.push({
|
||||
label: 'Remove from all shelves',
|
||||
label: this.t.translate('book.menuService.menu.removeFromAllShelves'),
|
||||
icon: 'pi pi-bookmark-fill', // Or bookmark-slash
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to remove ${count} book(s) from ALL their shelves?`,
|
||||
header: 'Confirm Unshelve',
|
||||
message: this.t.translate('book.menuService.confirm.unshelveMessage', {count}),
|
||||
header: this.t.translate('book.menuService.confirm.unshelveHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Removing ${count} book(s) from shelves...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.removingFromShelves', {count}));
|
||||
const books = this.bookService.getBooksByIdsFromState(Array.from(selectedBooks));
|
||||
const allShelfIds = new Set<number>();
|
||||
books.forEach(b => b.shelves?.forEach(s => {
|
||||
@@ -332,7 +334,7 @@ export class BookMenuService {
|
||||
|
||||
if (allShelfIds.size === 0) {
|
||||
this.loadingService.hide(loader);
|
||||
this.messageService.add({ severity: 'info', summary: 'Info', detail: 'Selected books are not on any shelves.' });
|
||||
this.messageService.add({ severity: 'info', summary: 'Info', detail: this.t.translate('book.menuService.toast.noBooksOnShelvesDetail') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,10 +342,10 @@ export class BookMenuService {
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({severity: 'success', summary: 'Success', detail: 'Books removed from all shelves'});
|
||||
this.messageService.add({severity: 'success', summary: this.t.translate('common.success'), detail: this.t.translate('book.menuService.toast.unshelveSuccessDetail')});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update books shelves'});
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: this.t.translate('book.menuService.toast.unshelveFailedDetail')});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -354,17 +356,17 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkResetBookloreReadProgress) {
|
||||
items.push({
|
||||
label: 'Reset Booklore Progress',
|
||||
label: this.t.translate('book.menuService.menu.resetBookloreProgress'),
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to reset Booklore reading progress for ${count} book(s)?`,
|
||||
header: 'Confirm Reset',
|
||||
message: this.t.translate('book.menuService.confirm.resetBookloreMessage', {count}),
|
||||
header: this.t.translate('book.menuService.confirm.resetHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Resetting Booklore progress for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.resettingBookloreProgress', {count}));
|
||||
|
||||
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.BOOKLORE)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
@@ -372,8 +374,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'Booklore reading progress has been reset.',
|
||||
summary: this.t.translate('book.menuService.toast.progressResetSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.bookloreProgressResetDetail'),
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
@@ -381,8 +383,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: apiError?.message || 'Could not reset progress.',
|
||||
summary: this.t.translate('book.menuService.toast.failedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.progressResetFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
@@ -395,17 +397,17 @@ export class BookMenuService {
|
||||
|
||||
if (permissions?.canBulkResetKoReaderReadProgress) {
|
||||
items.push({
|
||||
label: 'Reset KOReader Progress',
|
||||
label: this.t.translate('book.menuService.menu.resetKOReaderProgress'),
|
||||
icon: 'pi pi-undo',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to reset KOReader reading progress for ${count} book(s)?`,
|
||||
header: 'Confirm Reset',
|
||||
message: this.t.translate('book.menuService.confirm.resetKOReaderMessage', {count}),
|
||||
header: this.t.translate('book.menuService.confirm.resetHeader'),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'No',
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.no'),
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Resetting KOReader progress for ${count} book(s)...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.menuService.loading.resettingKOReaderProgress', {count}));
|
||||
|
||||
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.KOREADER)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
@@ -413,8 +415,8 @@ export class BookMenuService {
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Progress Reset',
|
||||
detail: 'KOReader reading progress has been reset.',
|
||||
summary: this.t.translate('book.menuService.toast.progressResetSummary'),
|
||||
detail: this.t.translate('book.menuService.toast.koreaderProgressResetDetail'),
|
||||
life: 1500
|
||||
});
|
||||
},
|
||||
@@ -422,8 +424,8 @@ export class BookMenuService {
|
||||
const apiError = err.error as APIException;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: apiError?.message || 'Could not reset progress.',
|
||||
summary: this.t.translate('book.menuService.toast.failedSummary'),
|
||||
detail: apiError?.message || this.t.translate('book.menuService.toast.progressResetFailedDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {Router} from '@angular/router';
|
||||
import {BookStateService} from './book-state.service';
|
||||
import {BookSocketService} from './book-socket.service';
|
||||
import {BookPatchService} from './book-patch.service';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
export interface BookStatusUpdateResponse {
|
||||
bookId: number;
|
||||
@@ -41,6 +42,7 @@ export class BookService {
|
||||
private bookStateService = inject(BookStateService);
|
||||
private bookSocketService = inject(BookSocketService);
|
||||
private bookPatchService = inject(BookPatchService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
private loading$: Observable<Book[]> | null = null;
|
||||
|
||||
@@ -210,22 +212,22 @@ export class BookService {
|
||||
if (response.failedFileDeletions?.length > 0) {
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Some files could not be deleted',
|
||||
detail: `Books: ${response.failedFileDeletions.join(', ')}`,
|
||||
summary: this.t.translate('book.bookService.toast.someFilesNotDeletedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.someFilesNotDeletedDetail', {fileNames: response.failedFileDeletions.join(', ')}),
|
||||
});
|
||||
} else {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Books Deleted',
|
||||
detail: `${idList.length} book(s) deleted successfully.`,
|
||||
summary: this.t.translate('book.bookService.toast.booksDeletedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.booksDeletedDetail', {count: idList.length}),
|
||||
});
|
||||
}
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Delete Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while deleting books.',
|
||||
summary: this.t.translate('book.bookService.toast.deleteFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.deleteFailedDetail'),
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
@@ -253,15 +255,15 @@ export class BookService {
|
||||
});
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Physical Book Created',
|
||||
detail: `"${newBook.metadata?.title || 'Book'}" has been added to your library.`
|
||||
summary: this.t.translate('book.bookService.toast.physicalBookCreatedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.physicalBookCreatedDetail', {title: newBook.metadata?.title || 'Book'})
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Creation Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while creating the physical book.'
|
||||
summary: this.t.translate('book.bookService.toast.creationFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.creationFailedDetail')
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
@@ -384,15 +386,15 @@ export class BookService {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'File Deleted',
|
||||
detail: 'Additional file deleted successfully.'
|
||||
summary: this.t.translate('book.bookService.toast.fileDeletedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.additionalFileDeletedDetail')
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Delete Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while deleting the file.'
|
||||
summary: this.t.translate('book.bookService.toast.fileDeleteFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.fileDeleteFailedDetail')
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
@@ -441,15 +443,15 @@ export class BookService {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'File Deleted',
|
||||
detail: 'Book file deleted successfully.'
|
||||
summary: this.t.translate('book.bookService.toast.fileDeletedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.bookFileDeletedDetail')
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Delete Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while deleting the file.'
|
||||
summary: this.t.translate('book.bookService.toast.fileDeleteFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.fileDeleteFailedDetail')
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
@@ -507,15 +509,15 @@ export class BookService {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'File Uploaded',
|
||||
detail: 'Additional file uploaded successfully.'
|
||||
summary: this.t.translate('book.bookService.toast.fileUploadedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.fileUploadedDetail')
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Upload Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while uploading the file.'
|
||||
summary: this.t.translate('book.bookService.toast.uploadFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.uploadFailedDetail')
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
@@ -644,8 +646,8 @@ export class BookService {
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Field Lock Update Failed',
|
||||
detail: 'Failed to update metadata field locks. Please try again.',
|
||||
summary: this.t.translate('book.bookService.toast.fieldLockFailedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.fieldLockFailedDetail'),
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
@@ -789,15 +791,15 @@ export class BookService {
|
||||
const fileCount = sourceBookIds.length;
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Files Attached',
|
||||
detail: `${fileCount} book file${fileCount > 1 ? 's have' : ' has'} been attached successfully.`
|
||||
summary: this.t.translate('book.bookService.toast.filesAttachedSummary'),
|
||||
detail: this.t.translate('book.bookService.toast.filesAttachedDetail', {count: fileCount})
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Attachment Failed',
|
||||
detail: error?.error?.message || error?.message || 'An error occurred while attaching the files.'
|
||||
summary: this.t.translate('book.bookService.toast.attachmentFailedSummary'),
|
||||
detail: error?.error?.message || error?.message || this.t.translate('book.bookService.toast.attachmentFailedDetail')
|
||||
});
|
||||
return throwError(() => error);
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {LoadingService} from '../../../core/services/loading.service';
|
||||
import {finalize} from 'rxjs';
|
||||
import {DialogLauncherService} from '../../../shared/services/dialog-launcher.service';
|
||||
import {BookDialogHelperService} from '../components/book-browser/book-dialog-helper.service';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -30,14 +31,15 @@ export class LibraryShelfMenuService {
|
||||
private userService = inject(UserService);
|
||||
private loadingService = inject(LoadingService);
|
||||
private bookDialogHelperService = inject(BookDialogHelperService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
initializeLibraryMenuItems(entity: Library | Shelf | MagicShelf | null): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: 'Options',
|
||||
label: this.t.translate('book.shelfMenuService.library.optionsLabel'),
|
||||
items: [
|
||||
{
|
||||
label: 'Add Physical Book',
|
||||
label: this.t.translate('book.shelfMenuService.library.addPhysicalBook'),
|
||||
icon: 'pi pi-book',
|
||||
command: () => {
|
||||
this.bookDialogHelperService.openAddPhysicalBookDialog(entity?.id as number);
|
||||
@@ -47,44 +49,44 @@ export class LibraryShelfMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Edit Library',
|
||||
label: this.t.translate('book.shelfMenuService.library.editLibrary'),
|
||||
icon: 'pi pi-pen-to-square',
|
||||
command: () => {
|
||||
this.dialogLauncherService.openLibraryEditDialog((entity?.id as number));
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Re-scan Library',
|
||||
label: this.t.translate('book.shelfMenuService.library.rescanLibrary'),
|
||||
icon: 'pi pi-refresh',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to refresh library: ${entity?.name}?`,
|
||||
header: 'Confirmation',
|
||||
message: this.t.translate('book.shelfMenuService.confirm.rescanLibraryMessage', {name: entity?.name}),
|
||||
header: this.t.translate('book.shelfMenuService.confirm.header'),
|
||||
icon: undefined,
|
||||
acceptLabel: 'Rescan',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptLabel: this.t.translate('book.shelfMenuService.confirm.rescanLabel'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptIcon: undefined,
|
||||
rejectIcon: undefined,
|
||||
acceptButtonStyleClass: undefined,
|
||||
rejectButtonStyleClass: undefined,
|
||||
rejectButtonProps: {
|
||||
label: 'Cancel',
|
||||
label: this.t.translate('common.cancel'),
|
||||
severity: 'secondary',
|
||||
},
|
||||
acceptButtonProps: {
|
||||
label: 'Rescan',
|
||||
label: this.t.translate('book.shelfMenuService.confirm.rescanLabel'),
|
||||
severity: 'success',
|
||||
},
|
||||
accept: () => {
|
||||
this.libraryService.refreshLibrary(entity?.id!).subscribe({
|
||||
complete: () => {
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library refresh scheduled'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfMenuService.toast.libraryRefreshSuccessDetail')});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Failed to refresh library',
|
||||
summary: this.t.translate('book.shelfMenuService.toast.failedSummary'),
|
||||
detail: this.t.translate('book.shelfMenuService.toast.libraryRefreshFailedDetail'),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -93,14 +95,14 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Custom Fetch Metadata',
|
||||
label: this.t.translate('book.shelfMenuService.library.customFetchMetadata'),
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogLauncherService.openLibraryMetadataFetchDialog((entity?.id as number));
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Auto Fetch Metadata',
|
||||
label: this.t.translate('book.shelfMenuService.library.autoFetchMetadata'),
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
this.taskHelperService.refreshMetadataTask({
|
||||
@@ -113,37 +115,37 @@ export class LibraryShelfMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Delete Library',
|
||||
label: this.t.translate('book.shelfMenuService.library.deleteLibrary'),
|
||||
icon: 'pi pi-trash',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete library: ${entity?.name}?`,
|
||||
header: 'Confirmation',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'Cancel',
|
||||
message: this.t.translate('book.shelfMenuService.confirm.deleteLibraryMessage', {name: entity?.name}),
|
||||
header: this.t.translate('book.shelfMenuService.confirm.header'),
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
rejectButtonProps: {
|
||||
label: 'Cancel',
|
||||
label: this.t.translate('common.cancel'),
|
||||
severity: 'secondary',
|
||||
},
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'danger',
|
||||
},
|
||||
accept: () => {
|
||||
const loader = this.loadingService.show(`Deleting library '${entity?.name}'...`);
|
||||
const loader = this.loadingService.show(this.t.translate('book.shelfMenuService.loading.deletingLibrary', {name: entity?.name}));
|
||||
|
||||
this.libraryService.deleteLibrary(entity?.id!)
|
||||
.pipe(finalize(() => this.loadingService.hide(loader)))
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this.router.navigate(['/']);
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library was deleted'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfMenuService.toast.libraryDeletedDetail')});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Failed to delete library',
|
||||
summary: this.t.translate('book.shelfMenuService.toast.failedSummary'),
|
||||
detail: this.t.translate('book.shelfMenuService.toast.libraryDeleteFailedDetail'),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -164,10 +166,10 @@ export class LibraryShelfMenuService {
|
||||
|
||||
return [
|
||||
{
|
||||
label: (isPublicShelf ? 'Public Shelf - ' : '') + (disableOptions ? 'Read only' : 'Options'),
|
||||
label: (isPublicShelf ? this.t.translate('book.shelfMenuService.shelf.publicShelfPrefix') : '') + (disableOptions ? this.t.translate('book.shelfMenuService.shelf.readOnly') : this.t.translate('book.shelfMenuService.shelf.optionsLabel')),
|
||||
items: [
|
||||
{
|
||||
label: 'Edit Shelf',
|
||||
label: this.t.translate('book.shelfMenuService.shelf.editShelf'),
|
||||
icon: 'pi pi-pen-to-square',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
@@ -178,34 +180,34 @@ export class LibraryShelfMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Delete Shelf',
|
||||
label: this.t.translate('book.shelfMenuService.shelf.deleteShelf'),
|
||||
icon: 'pi pi-trash',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete shelf: ${entity?.name}?`,
|
||||
header: 'Confirmation',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'Cancel',
|
||||
message: this.t.translate('book.shelfMenuService.confirm.deleteShelfMessage', {name: entity?.name}),
|
||||
header: this.t.translate('book.shelfMenuService.confirm.header'),
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'danger'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'Cancel',
|
||||
label: this.t.translate('common.cancel'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
this.shelfService.deleteShelf(entity?.id!).subscribe({
|
||||
complete: () => {
|
||||
this.router.navigate(['/']);
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Shelf was deleted'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfMenuService.toast.shelfDeletedDetail')});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Failed to delete shelf',
|
||||
summary: this.t.translate('book.shelfMenuService.toast.failedSummary'),
|
||||
detail: this.t.translate('book.shelfMenuService.toast.shelfDeleteFailedDetail'),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -225,10 +227,10 @@ export class LibraryShelfMenuService {
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Options',
|
||||
label: this.t.translate('book.shelfMenuService.magicShelf.optionsLabel'),
|
||||
items: [
|
||||
{
|
||||
label: 'Edit Magic Shelf',
|
||||
label: this.t.translate('book.shelfMenuService.magicShelf.editMagicShelf'),
|
||||
icon: 'pi pi-pen-to-square',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
@@ -239,34 +241,34 @@ export class LibraryShelfMenuService {
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Delete Magic Shelf',
|
||||
label: this.t.translate('book.shelfMenuService.magicShelf.deleteMagicShelf'),
|
||||
icon: 'pi pi-trash',
|
||||
disabled: disableOptions,
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete magic shelf: ${entity?.name}?`,
|
||||
header: 'Confirmation',
|
||||
acceptLabel: 'Yes',
|
||||
rejectLabel: 'Cancel',
|
||||
message: this.t.translate('book.shelfMenuService.confirm.deleteMagicShelfMessage', {name: entity?.name}),
|
||||
header: this.t.translate('book.shelfMenuService.confirm.header'),
|
||||
acceptLabel: this.t.translate('common.yes'),
|
||||
rejectLabel: this.t.translate('common.cancel'),
|
||||
acceptButtonProps: {
|
||||
label: 'Yes',
|
||||
label: this.t.translate('common.yes'),
|
||||
severity: 'danger'
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'Cancel',
|
||||
label: this.t.translate('common.cancel'),
|
||||
severity: 'secondary'
|
||||
},
|
||||
accept: () => {
|
||||
this.magicShelfService.deleteShelf(entity?.id!).subscribe({
|
||||
complete: () => {
|
||||
this.router.navigate(['/']);
|
||||
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Magic shelf was deleted'});
|
||||
this.messageService.add({severity: 'info', summary: this.t.translate('common.success'), detail: this.t.translate('book.shelfMenuService.toast.magicShelfDeletedDetail')});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Failed',
|
||||
detail: 'Failed to delete shelf',
|
||||
summary: this.t.translate('book.shelfMenuService.toast.failedSummary'),
|
||||
detail: this.t.translate('book.shelfMenuService.toast.magicShelfDeleteFailedDetail'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerAudiobook'">
|
||||
@if (isLoading) {
|
||||
<div class="loading-container">
|
||||
<p-progressSpinner strokeWidth="3" />
|
||||
<span class="loading-text">Loading audiobook...</span>
|
||||
<span class="loading-text">{{ t('loading') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="audiobook-player">
|
||||
@@ -23,12 +24,12 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(onClick)="closeReader()"
|
||||
pTooltip="Back"
|
||||
[pTooltip]="t('header.backTooltip')"
|
||||
tooltipPosition="bottom"
|
||||
/>
|
||||
<div class="header-info">
|
||||
<h1 class="book-title">{{ audiobookInfo.title || 'Untitled' }}</h1>
|
||||
<span class="book-author">{{ audiobookInfo.author || 'Unknown Author' }}</span>
|
||||
<h1 class="book-title">{{ audiobookInfo.title || t('untitled') }}</h1>
|
||||
<span class="book-author">{{ audiobookInfo.author || t('unknownAuthor') }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<p-button
|
||||
@@ -37,7 +38,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(onClick)="toggleBookmarkList()"
|
||||
pTooltip="Bookmarks"
|
||||
[pTooltip]="t('header.bookmarksTooltip')"
|
||||
tooltipPosition="bottom"
|
||||
/>
|
||||
<p-button
|
||||
@@ -46,7 +47,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(onClick)="toggleTrackList()"
|
||||
pTooltip="Chapters/Tracks"
|
||||
[pTooltip]="t('header.chaptersTracksTooltip')"
|
||||
tooltipPosition="bottom"
|
||||
/>
|
||||
</div>
|
||||
@@ -61,7 +62,7 @@
|
||||
<img
|
||||
[src]="coverUrl"
|
||||
(error)="onCoverError()"
|
||||
alt="Cover"
|
||||
[alt]="t('coverAlt')"
|
||||
class="cover-image"
|
||||
/>
|
||||
} @else {
|
||||
@@ -81,11 +82,11 @@
|
||||
<div class="track-info">
|
||||
@if (audiobookInfo.folderBased && currentTrack) {
|
||||
<span class="track-title">{{ currentTrack.title }}</span>
|
||||
<span class="track-number">Track {{ currentTrackIndex + 1 }} of {{ audiobookInfo.tracks?.length }}</span>
|
||||
<span class="track-number">{{ t('trackInfo.trackOf', { current: currentTrackIndex + 1, total: audiobookInfo.tracks?.length }) }}</span>
|
||||
} @else if (getCurrentChapter()) {
|
||||
<span class="track-title">{{ getCurrentChapter()?.title }}</span>
|
||||
@if (hasMultipleChapters()) {
|
||||
<span class="track-number">Chapter {{ getCurrentChapterIndex() + 1 }} of {{ audiobookInfo.chapters?.length }}</span>
|
||||
<span class="track-number">{{ t('trackInfo.chapterOf', { current: getCurrentChapterIndex() + 1, total: audiobookInfo.chapters?.length }) }}</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -119,7 +120,7 @@
|
||||
severity="secondary"
|
||||
(onClick)="previousTrack()"
|
||||
[disabled]="currentTrackIndex === 0"
|
||||
pTooltip="Previous Track"
|
||||
[pTooltip]="t('controls.previousTrackTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -131,7 +132,7 @@
|
||||
severity="secondary"
|
||||
(onClick)="previousChapter()"
|
||||
[disabled]="!canGoPreviousChapter()"
|
||||
pTooltip="Previous Chapter"
|
||||
[pTooltip]="t('controls.previousChapterTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -143,7 +144,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(onClick)="seekRelative(-30)"
|
||||
pTooltip="-30s"
|
||||
[pTooltip]="t('controls.rewindTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -164,7 +165,7 @@
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
(onClick)="seekRelative(30)"
|
||||
pTooltip="+30s"
|
||||
[pTooltip]="t('controls.forwardTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -177,7 +178,7 @@
|
||||
severity="secondary"
|
||||
(onClick)="nextTrack()"
|
||||
[disabled]="!audiobookInfo.tracks || currentTrackIndex >= audiobookInfo.tracks.length - 1"
|
||||
pTooltip="Next Track"
|
||||
[pTooltip]="t('controls.nextTrackTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -189,7 +190,7 @@
|
||||
severity="secondary"
|
||||
(onClick)="nextChapter()"
|
||||
[disabled]="!canGoNextChapter()"
|
||||
pTooltip="Next Chapter"
|
||||
[pTooltip]="t('controls.nextChapterTooltip')"
|
||||
tooltipPosition="top"
|
||||
size="large"
|
||||
/>
|
||||
@@ -236,7 +237,7 @@
|
||||
<!-- Add bookmark button -->
|
||||
<p-button
|
||||
icon="pi pi-bookmark-fill"
|
||||
label="Add Bookmark"
|
||||
[label]="t('extra.addBookmark')"
|
||||
[rounded]="true"
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
@@ -248,7 +249,7 @@
|
||||
<p-button
|
||||
#sleepTimerBtn
|
||||
icon="pi pi-moon"
|
||||
[label]="sleepTimerActive ? formatSleepTimerRemaining() : 'Sleep Timer'"
|
||||
[label]="sleepTimerActive ? formatSleepTimerRemaining() : t('extra.sleepTimer')"
|
||||
[rounded]="true"
|
||||
[text]="true"
|
||||
severity="secondary"
|
||||
@@ -269,19 +270,19 @@
|
||||
@if (audiobookInfo.narrator) {
|
||||
<span class="detail-item">
|
||||
<i class="pi pi-user"></i>
|
||||
Narrated by {{ audiobookInfo.narrator }}
|
||||
{{ t('details.narratedBy', { narrator: audiobookInfo.narrator }) }}
|
||||
</span>
|
||||
}
|
||||
@if (audiobookInfo.bitrate) {
|
||||
<span class="detail-item">
|
||||
<i class="pi pi-sliders-h"></i>
|
||||
{{ audiobookInfo.bitrate }} kbps
|
||||
{{ t('details.kbps', { bitrate: audiobookInfo.bitrate }) }}
|
||||
</span>
|
||||
}
|
||||
@if (audiobookInfo.durationMs) {
|
||||
<span class="detail-item">
|
||||
<i class="pi pi-clock"></i>
|
||||
{{ formatDuration(audiobookInfo.durationMs) }} total
|
||||
{{ t('details.totalDuration', { duration: formatDuration(audiobookInfo.durationMs) }) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@@ -291,7 +292,7 @@
|
||||
@if (showTrackList) {
|
||||
<aside class="track-list-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>{{ audiobookInfo.folderBased ? 'Tracks' : 'Chapters' }}</h2>
|
||||
<h2>{{ audiobookInfo.folderBased ? t('sidebar.tracks') : t('sidebar.chapters') }}</h2>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
[rounded]="true"
|
||||
@@ -348,7 +349,7 @@
|
||||
@if (showBookmarkList) {
|
||||
<aside class="track-list-sidebar bookmark-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Bookmarks</h2>
|
||||
<h2>{{ t('bookmarks.title') }}</h2>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
[rounded]="true"
|
||||
@@ -361,8 +362,8 @@
|
||||
@if (bookmarks.length === 0) {
|
||||
<div class="empty-bookmarks">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<p>No bookmarks yet</p>
|
||||
<span>Add a bookmark to save your place</span>
|
||||
<p>{{ t('bookmarks.empty') }}</p>
|
||||
<span>{{ t('bookmarks.emptyHint') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="track-list">
|
||||
@@ -383,7 +384,7 @@
|
||||
severity="danger"
|
||||
size="small"
|
||||
(onClick)="deleteBookmark($event, bookmark.id)"
|
||||
pTooltip="Delete"
|
||||
[pTooltip]="t('bookmarks.deleteTooltip')"
|
||||
tooltipPosition="left"
|
||||
/>
|
||||
</li>
|
||||
@@ -395,3 +396,4 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {Tooltip} from 'primeng/tooltip';
|
||||
import {MenuItem, MessageService} from 'primeng/api';
|
||||
import {SelectButton} from 'primeng/selectbutton';
|
||||
import {Menu} from 'primeng/menu';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
import {AudiobookService} from './audiobook.service';
|
||||
import {AudiobookChapter, AudiobookInfo, AudiobookProgress, AudiobookTrack} from './audiobook.model';
|
||||
@@ -33,7 +34,8 @@ import {API_CONFIG} from '../../../core/config/api-config';
|
||||
ProgressSpinner,
|
||||
Tooltip,
|
||||
SelectButton,
|
||||
Menu
|
||||
Menu,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './audiobook-player.component.html',
|
||||
styleUrls: ['./audiobook-player.component.scss']
|
||||
@@ -51,6 +53,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
private messageService = inject(MessageService);
|
||||
private audiobookSessionService = inject(AudiobookSessionService);
|
||||
private pageTitle = inject(PageTitleService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
isLoading = true;
|
||||
audioLoading = false;
|
||||
@@ -84,15 +87,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
private sleepTimerInterval?: ReturnType<typeof setInterval>;
|
||||
private originalVolume = 1;
|
||||
|
||||
sleepTimerOptions: MenuItem[] = [
|
||||
{label: '15 minutes', command: () => this.setSleepTimer(15)},
|
||||
{label: '30 minutes', command: () => this.setSleepTimer(30)},
|
||||
{label: '45 minutes', command: () => this.setSleepTimer(45)},
|
||||
{label: '60 minutes', command: () => this.setSleepTimer(60)},
|
||||
{label: 'End of chapter', command: () => this.setSleepTimerEndOfChapter()},
|
||||
{separator: true},
|
||||
{label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false}
|
||||
];
|
||||
sleepTimerOptions: MenuItem[] = [];
|
||||
|
||||
bookmarks: BookMark[] = [];
|
||||
|
||||
@@ -111,6 +106,16 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
private isSeeking = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sleepTimerOptions = [
|
||||
{label: this.t.translate('readerAudiobook.sleepTimerMenu.minutes15'), command: () => this.setSleepTimer(15)},
|
||||
{label: this.t.translate('readerAudiobook.sleepTimerMenu.minutes30'), command: () => this.setSleepTimer(30)},
|
||||
{label: this.t.translate('readerAudiobook.sleepTimerMenu.minutes45'), command: () => this.setSleepTimer(45)},
|
||||
{label: this.t.translate('readerAudiobook.sleepTimerMenu.minutes60'), command: () => this.setSleepTimer(60)},
|
||||
{label: this.t.translate('readerAudiobook.sleepTimerMenu.endOfChapter'), command: () => this.setSleepTimerEndOfChapter()},
|
||||
{separator: true},
|
||||
{id: 'cancel-timer', label: this.t.translate('readerAudiobook.sleepTimerMenu.cancelTimer'), command: () => this.cancelSleepTimer(), visible: false}
|
||||
];
|
||||
|
||||
this.route.paramMap.pipe(takeUntil(this.destroy$)).subscribe(params => {
|
||||
this.bookId = +params.get('bookId')!;
|
||||
this.loadAudiobook();
|
||||
@@ -174,8 +179,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load audiobook'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('readerAudiobook.toast.loadFailed')
|
||||
});
|
||||
this.isLoading = false;
|
||||
this.audioLoading = false;
|
||||
@@ -345,8 +350,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
this.audioLoading = false;
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load audio'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('readerAudiobook.toast.audioLoadFailed')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,8 +393,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
: this.getCurrentChapter()?.title || this.audiobookInfo.title;
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: title || 'Untitled',
|
||||
artist: this.audiobookInfo.author || 'Unknown Author',
|
||||
title: title || this.t.translate('readerAudiobook.untitled'),
|
||||
artist: this.audiobookInfo.author || this.t.translate('readerAudiobook.unknownAuthor'),
|
||||
album: this.audiobookInfo.title,
|
||||
artwork: this.coverUrl
|
||||
? [{src: this.coverUrl, sizes: '512x512', type: 'image/png'}]
|
||||
@@ -850,8 +855,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: 'Sleep Timer',
|
||||
detail: `Playback will stop in ${minutes} minutes`
|
||||
summary: this.t.translate('readerAudiobook.extra.sleepTimer'),
|
||||
detail: this.t.translate('readerAudiobook.toast.sleepTimerSet', {minutes})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -865,8 +870,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: 'Sleep Timer',
|
||||
detail: 'Playback will stop at end of chapter'
|
||||
summary: this.t.translate('readerAudiobook.extra.sleepTimer'),
|
||||
detail: this.t.translate('readerAudiobook.toast.sleepTimerEndOfChapter')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -906,13 +911,13 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: 'Sleep Timer',
|
||||
detail: 'Playback stopped by sleep timer'
|
||||
summary: this.t.translate('readerAudiobook.extra.sleepTimer'),
|
||||
detail: this.t.translate('readerAudiobook.toast.sleepTimerStopped')
|
||||
});
|
||||
}
|
||||
|
||||
private updateSleepTimerMenuVisibility(): void {
|
||||
const cancelItem = this.sleepTimerOptions.find(item => item.label === 'Cancel timer');
|
||||
const cancelItem = this.sleepTimerOptions.find(item => item.id === 'cancel-timer');
|
||||
if (cancelItem) {
|
||||
cancelItem.visible = this.sleepTimerActive;
|
||||
}
|
||||
@@ -920,7 +925,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
formatSleepTimerRemaining(): string {
|
||||
if (this.sleepTimerEndOfChapter) {
|
||||
return 'End of chapter';
|
||||
return this.t.translate('readerAudiobook.extra.endOfChapter');
|
||||
}
|
||||
const minutes = Math.floor(this.sleepTimerRemaining / 60);
|
||||
const seconds = this.sleepTimerRemaining % 60;
|
||||
@@ -964,7 +969,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
} else if (currentChapter) {
|
||||
title = `${currentChapter.title} - ${this.formatTime(this.currentTime)}`;
|
||||
} else {
|
||||
title = `Bookmark at ${this.formatTime(this.currentTime)}`;
|
||||
title = this.t.translate('readerAudiobook.bookmarks.bookmarkAt', {time: this.formatTime(this.currentTime)});
|
||||
}
|
||||
|
||||
const request: CreateBookMarkRequest = {
|
||||
@@ -981,7 +986,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
this.bookmarks = [...this.bookmarks, bookmark];
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Bookmark Added',
|
||||
summary: this.t.translate('readerAudiobook.toast.bookmarkAdded'),
|
||||
detail: title
|
||||
});
|
||||
},
|
||||
@@ -989,8 +994,8 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
const isDuplicate = err?.status === 409;
|
||||
this.messageService.add({
|
||||
severity: isDuplicate ? 'warn' : 'error',
|
||||
summary: isDuplicate ? 'Bookmark Exists' : 'Error',
|
||||
detail: isDuplicate ? 'A bookmark already exists at this position' : 'Failed to add bookmark'
|
||||
summary: isDuplicate ? this.t.translate('readerAudiobook.toast.bookmarkExists') : this.t.translate('common.error'),
|
||||
detail: isDuplicate ? this.t.translate('readerAudiobook.toast.bookmarkExistsDetail') : this.t.translate('readerAudiobook.toast.bookmarkFailed')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1079,7 +1084,7 @@ export class AudiobookPlayerComponent implements OnInit, OnDestroy {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
|
||||
this.messageService.add({
|
||||
severity: 'info',
|
||||
summary: 'Bookmark Deleted'
|
||||
summary: this.t.translate('readerAudiobook.toast.bookmarkDeleted')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="end-of-comic-action">
|
||||
<button class="next-book-action-button" (click)="navigateToNextBook()">
|
||||
<span class="action-icon">📖</span>
|
||||
<span class="action-text">Continue to Next Book</span>
|
||||
<span class="action-text">{{ 'readerCbx.reader.continueToNextBook' | transloco }}</span>
|
||||
<span class="book-title">{{ getBookDisplayTitle(nextBookInSeries) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
<div class="end-of-comic-action">
|
||||
<button class="next-book-action-button" (click)="navigateToNextBook()">
|
||||
<span class="action-icon">📖</span>
|
||||
<span class="action-text">Continue to Next Book</span>
|
||||
<span class="action-text">{{ 'readerCbx.reader.continueToNextBook' | transloco }}</span>
|
||||
<span class="book-title">{{ getBookDisplayTitle(nextBookInSeries) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,14 +98,14 @@
|
||||
}
|
||||
} @else {
|
||||
<div class="no-pages">
|
||||
<p>No pages available.</p>
|
||||
<p>{{ 'readerCbx.reader.noPagesAvailable' | transloco }}</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="loader-overlay">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
<p class="loader-text">Loading book...</p>
|
||||
<p class="loader-text">{{ 'readerCbx.reader.loadingBook' | transloco }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {CbxReaderService} from '../../book/service/cbx-reader.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrollMode, CbxReadingDirection, CbxSlideshowInterval, UserService} from '../../settings/user-management/user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {Book, BookSetting, BookType} from '../../book/model/book.model';
|
||||
import {BookState} from '../../book/model/state/book-state.model';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
@@ -35,6 +36,7 @@ import {BookNoteV2} from '../../../shared/service/book-note-v2.service';
|
||||
CommonModule,
|
||||
ProgressSpinner,
|
||||
FormsModule,
|
||||
TranslocoPipe,
|
||||
CbxHeaderComponent,
|
||||
CbxSidebarComponent,
|
||||
CbxFooterComponent,
|
||||
@@ -126,6 +128,7 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
private bookService = inject(BookService);
|
||||
private userService = inject(UserService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private pageTitle = inject(PageTitleService);
|
||||
private readingSessionService = inject(ReadingSessionService);
|
||||
private headerService = inject(CbxHeaderService);
|
||||
@@ -247,15 +250,15 @@ export class CbxReaderComponent implements OnInit, OnDestroy {
|
||||
this.readingSessionService.startSession(this.bookId, "CBX", (this.currentPage + 1).toString(), percentage);
|
||||
},
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || 'Failed to load pages';
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: errorMessage});
|
||||
const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadPages');
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage});
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || 'Failed to load the book';
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: errorMessage});
|
||||
const errorMessage = err?.error?.message || this.t.translate('shared.reader.failedToLoadBook');
|
||||
this.messageService.add({severity: 'error', summary: this.t.translate('common.error'), detail: errorMessage});
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="dialog-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>{{ isEditing ? 'Edit Note' : 'Add Note' }}</h2>
|
||||
<h2>{{ isEditing ? ('readerCbx.noteDialog.editNote' | transloco) : ('readerCbx.noteDialog.addNote' | transloco) }}</h2>
|
||||
<button class="close-btn" (click)="onCancel()">
|
||||
<app-reader-icon name="close" [size]="18"></app-reader-icon>
|
||||
</button>
|
||||
@@ -9,24 +9,24 @@
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="page-info-section">
|
||||
<span class="section-label">Page</span>
|
||||
<p class="page-info">Page {{ data?.pageNumber }}</p>
|
||||
<span class="section-label">{{ 'readerCbx.noteDialog.pageLabel' | transloco }}</span>
|
||||
<p class="page-info">{{ 'readerCbx.noteDialog.pageInfo' | transloco: { pageNumber: data?.pageNumber } }}</p>
|
||||
</div>
|
||||
|
||||
<div class="note-section">
|
||||
<label class="section-label" for="noteContent">Your Note</label>
|
||||
<label class="section-label" for="noteContent">{{ 'readerCbx.noteDialog.yourNote' | transloco }}</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
class="note-input"
|
||||
[(ngModel)]="noteContent"
|
||||
placeholder="Write your note here..."
|
||||
[placeholder]="'readerCbx.noteDialog.placeholder' | transloco"
|
||||
rows="6"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="color-section">
|
||||
<span class="section-label">Note Color</span>
|
||||
<span class="section-label">{{ 'readerCbx.noteDialog.noteColor' | transloco }}</span>
|
||||
<div class="color-buttons">
|
||||
@for (color of noteColors; track color.value) {
|
||||
<button
|
||||
@@ -42,8 +42,8 @@
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-secondary" (click)="onCancel()">Cancel</button>
|
||||
<button class="btn btn-primary" (click)="onSave()" [disabled]="!noteContent.trim()">{{ isEditing ? 'Update Note' : 'Save Note' }}</button>
|
||||
<button class="btn btn-secondary" (click)="onCancel()">{{ 'common.cancel' | transloco }}</button>
|
||||
<button class="btn btn-primary" (click)="onSave()" [disabled]="!noteContent.trim()">{{ isEditing ? ('readerCbx.noteDialog.updateNote' | transloco) : ('readerCbx.noteDialog.saveNote' | transloco) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, EventEmitter, Input, Output, OnChanges, SimpleChanges} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Input, Output, OnChanges, SimpleChanges} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {TranslocoService, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {ReaderIconComponent} from '../../ebook-reader/shared/icon.component';
|
||||
|
||||
export interface CbxNoteDialogData {
|
||||
@@ -18,11 +19,13 @@ export interface CbxNoteDialogResult {
|
||||
@Component({
|
||||
selector: 'app-cbx-note-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent],
|
||||
imports: [CommonModule, FormsModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './cbx-note-dialog.component.html',
|
||||
styleUrls: ['./cbx-note-dialog.component.scss']
|
||||
})
|
||||
export class CbxNoteDialogComponent implements OnChanges {
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
@Input() data: CbxNoteDialogData | null = null;
|
||||
@Output() save = new EventEmitter<CbxNoteDialogResult>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
@@ -41,14 +44,16 @@ export class CbxNoteDialogComponent implements OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
noteColors = [
|
||||
{value: '#FFC107', label: 'Amber'},
|
||||
{value: '#4CAF50', label: 'Green'},
|
||||
{value: '#2196F3', label: 'Blue'},
|
||||
{value: '#E91E63', label: 'Pink'},
|
||||
{value: '#9C27B0', label: 'Purple'},
|
||||
{value: '#FF5722', label: 'Deep Orange'}
|
||||
];
|
||||
get noteColors() {
|
||||
return [
|
||||
{value: '#FFC107', label: this.t.translate('readerCbx.noteDialog.colorAmber')},
|
||||
{value: '#4CAF50', label: this.t.translate('readerCbx.noteDialog.colorGreen')},
|
||||
{value: '#2196F3', label: this.t.translate('readerCbx.noteDialog.colorBlue')},
|
||||
{value: '#E91E63', label: this.t.translate('readerCbx.noteDialog.colorPink')},
|
||||
{value: '#9C27B0', label: this.t.translate('readerCbx.noteDialog.colorPurple')},
|
||||
{value: '#FF5722', label: this.t.translate('readerCbx.noteDialog.colorDeepOrange')}
|
||||
];
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
if (this.noteContent.trim()) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="dialog-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<h2>{{ 'readerCbx.shortcutsHelp.title' | transloco }}</h2>
|
||||
<button class="close-btn" (click)="onClose()">
|
||||
<app-reader-icon name="close" [size]="18"></app-reader-icon>
|
||||
</button>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-primary" (click)="onClose()">Got it</button>
|
||||
<button class="btn btn-primary" (click)="onClose()">{{ 'readerCbx.shortcutsHelp.gotIt' | transloco }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, EventEmitter, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoService, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {ReaderIconComponent} from '../../ebook-reader/shared/icon.component';
|
||||
|
||||
interface ShortcutItem {
|
||||
@@ -16,48 +17,52 @@ interface ShortcutGroup {
|
||||
@Component({
|
||||
selector: 'app-cbx-shortcuts-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
imports: [CommonModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './cbx-shortcuts-help.component.html',
|
||||
styleUrls: ['./cbx-shortcuts-help.component.scss']
|
||||
})
|
||||
export class CbxShortcutsHelpComponent {
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{keys: ['←', '→'], description: 'Previous / Next page', mobileGesture: 'Swipe left/right'},
|
||||
{keys: ['Space'], description: 'Next page'},
|
||||
{keys: ['Shift', 'Space'], description: 'Previous page'},
|
||||
{keys: ['Home'], description: 'First page'},
|
||||
{keys: ['End'], description: 'Last page'},
|
||||
{keys: ['Page Up'], description: 'Previous page'},
|
||||
{keys: ['Page Down'], description: 'Next page'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: 'Toggle fullscreen'},
|
||||
{keys: ['D'], description: 'Toggle reading direction (LTR/RTL)'},
|
||||
{keys: ['Escape'], description: 'Exit fullscreen / Close dialogs'},
|
||||
{keys: ['Double-click'], description: 'Toggle zoom (fit page / actual size)', mobileGesture: 'Double-tap'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Playback',
|
||||
shortcuts: [
|
||||
{keys: ['P'], description: 'Toggle slideshow / auto-play'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
shortcuts: [
|
||||
{keys: ['?'], description: 'Show this help dialog'}
|
||||
]
|
||||
}
|
||||
];
|
||||
get shortcutGroups(): ShortcutGroup[] {
|
||||
return [
|
||||
{
|
||||
title: this.t.translate('readerCbx.shortcutsHelp.groupNavigation'),
|
||||
shortcuts: [
|
||||
{keys: ['←', '→'], description: this.t.translate('readerCbx.shortcutsHelp.previousNextPage'), mobileGesture: this.t.translate('readerCbx.shortcutsHelp.swipeLeftRight')},
|
||||
{keys: ['Space'], description: this.t.translate('readerCbx.shortcutsHelp.nextPage')},
|
||||
{keys: ['Shift', 'Space'], description: this.t.translate('readerCbx.shortcutsHelp.previousPage')},
|
||||
{keys: ['Home'], description: this.t.translate('readerCbx.shortcutsHelp.firstPage')},
|
||||
{keys: ['End'], description: this.t.translate('readerCbx.shortcutsHelp.lastPage')},
|
||||
{keys: ['Page Up'], description: this.t.translate('readerCbx.shortcutsHelp.previousPage')},
|
||||
{keys: ['Page Down'], description: this.t.translate('readerCbx.shortcutsHelp.nextPage')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerCbx.shortcutsHelp.groupDisplay'),
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: this.t.translate('readerCbx.shortcutsHelp.toggleFullscreen')},
|
||||
{keys: ['D'], description: this.t.translate('readerCbx.shortcutsHelp.toggleReadingDirection')},
|
||||
{keys: ['Escape'], description: this.t.translate('readerCbx.shortcutsHelp.exitFullscreenCloseDialogs')},
|
||||
{keys: ['Double-click'], description: this.t.translate('readerCbx.shortcutsHelp.toggleZoom'), mobileGesture: this.t.translate('readerCbx.shortcutsHelp.doubleTap')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerCbx.shortcutsHelp.groupPlayback'),
|
||||
shortcuts: [
|
||||
{keys: ['P'], description: this.t.translate('readerCbx.shortcutsHelp.toggleSlideshow')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerCbx.shortcutsHelp.groupOther'),
|
||||
shortcuts: [
|
||||
{keys: ['?'], description: this.t.translate('readerCbx.shortcutsHelp.showHelpDialog')}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
isMobile = window.innerWidth < 768;
|
||||
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
[disabled]="!state.previousBookInSeries"
|
||||
[title]="getPreviousBookTooltip()">
|
||||
<app-reader-icon name="chevron-double-left" [size]="14" />
|
||||
<span class="label hide-on-mobile">Prev Book</span>
|
||||
<span class="label hide-on-mobile">{{ 'readerCbx.footer.prevBook' | transloco }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<div class="page-nav-left">
|
||||
<button class="nav-btn" (click)="onFirstPage()" [disabled]="!canGoPrevious" title="First Page">
|
||||
<button class="nav-btn" (click)="onFirstPage()" [disabled]="!canGoPrevious" [title]="'readerCbx.footer.firstPage' | transloco">
|
||||
<app-reader-icon name="chevron-first" [size]="16" />
|
||||
</button>
|
||||
<button class="nav-btn" (click)="onPreviousPage()" [disabled]="!canGoPrevious" title="Previous Page">
|
||||
<button class="nav-btn" (click)="onPreviousPage()" [disabled]="!canGoPrevious" [title]="'readerCbx.footer.previousPage' | transloco">
|
||||
<app-reader-icon name="chevron-left" [size]="16" />
|
||||
</button>
|
||||
<div class="page-info">
|
||||
@@ -23,13 +23,13 @@
|
||||
@if (displaySecondPage) {
|
||||
<span class="separator">-{{ displaySecondPage }}</span>
|
||||
}
|
||||
<span class="total"> of {{ state.totalPages }}</span>
|
||||
<span class="total"> {{ 'readerCbx.footer.of' | transloco }} {{ state.totalPages }}</span>
|
||||
</div>
|
||||
|
||||
<button class="nav-btn" (click)="onNextPage()" [disabled]="!canGoNext" title="Next Page">
|
||||
<button class="nav-btn" (click)="onNextPage()" [disabled]="!canGoNext" [title]="'readerCbx.footer.nextPage' | transloco">
|
||||
<app-reader-icon name="chevron-right" [size]="16" />
|
||||
</button>
|
||||
<button class="nav-btn" (click)="onLastPage()" [disabled]="!canGoNext" title="Last Page">
|
||||
<button class="nav-btn" (click)="onLastPage()" [disabled]="!canGoNext" [title]="'readerCbx.footer.lastPage' | transloco">
|
||||
<app-reader-icon name="chevron-last" [size]="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@
|
||||
[value]="sliderValue"
|
||||
(input)="onSliderChange($event)"
|
||||
list="page-ticks"
|
||||
title="Page Slider"/>
|
||||
[title]="'readerCbx.footer.pageSlider' | transloco"/>
|
||||
<datalist id="page-ticks">
|
||||
@for (tick of sliderTicks; track tick) {
|
||||
<option [value]="tick"></option>
|
||||
@@ -59,7 +59,7 @@
|
||||
[(ngModel)]="goToPageInput"
|
||||
[min]="1"
|
||||
[max]="state.totalPages"
|
||||
placeholder="Page"
|
||||
[placeholder]="'readerCbx.footer.pagePlaceholder' | transloco"
|
||||
class="page-input"
|
||||
(keyup.enter)="onGoToPage()"
|
||||
/>
|
||||
@@ -67,7 +67,7 @@
|
||||
class="go-btn"
|
||||
(click)="onGoToPage()"
|
||||
[disabled]="goToPageInput === null || goToPageInput < 1 || goToPageInput > state.totalPages">
|
||||
Go
|
||||
{{ 'readerCbx.footer.go' | transloco }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
(click)="onNextBook()"
|
||||
[disabled]="!state.nextBookInSeries"
|
||||
[title]="getNextBookTooltip()">
|
||||
<span class="label hide-on-mobile">Next Book</span>
|
||||
<span class="label hide-on-mobile">{{ 'readerCbx.footer.nextBook' | transloco }}</span>
|
||||
<app-reader-icon name="chevron-double-right" [size]="14" />
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {TranslocoService, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {Book} from '../../../../book/model/book.model';
|
||||
@@ -10,12 +11,13 @@ import {CbxFooterService, CbxFooterState} from './cbx-footer.service';
|
||||
@Component({
|
||||
selector: 'app-cbx-footer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent],
|
||||
imports: [CommonModule, FormsModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './cbx-footer.component.html',
|
||||
styleUrls: ['./cbx-footer.component.scss']
|
||||
})
|
||||
export class CbxFooterComponent implements OnInit, OnDestroy {
|
||||
private footerService = inject(CbxFooterService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
isVisible = false;
|
||||
@@ -144,13 +146,13 @@ export class CbxFooterComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
getPreviousBookTooltip(): string {
|
||||
if (!this.state.previousBookInSeries) return 'No Previous Book';
|
||||
return `Previous: ${this.getBookDisplayTitle(this.state.previousBookInSeries)}`;
|
||||
if (!this.state.previousBookInSeries) return this.t.translate('readerCbx.footer.noPreviousBook');
|
||||
return this.t.translate('readerCbx.footer.previousBookTooltip', { title: this.getBookDisplayTitle(this.state.previousBookInSeries) });
|
||||
}
|
||||
|
||||
getNextBookTooltip(): string {
|
||||
if (!this.state.nextBookInSeries) return 'No Next Book';
|
||||
return `Next: ${this.getBookDisplayTitle(this.state.nextBookInSeries)}`;
|
||||
if (!this.state.nextBookInSeries) return this.t.translate('readerCbx.footer.noNextBook');
|
||||
return this.t.translate('readerCbx.footer.nextBookTooltip', { title: this.getBookDisplayTitle(this.state.nextBookInSeries) });
|
||||
}
|
||||
|
||||
private getBookDisplayTitle(book: Book): string {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<div class="cbx-header" [class.visible]="isVisible">
|
||||
<div class="header-left">
|
||||
<button class="icon-btn" (click)="onOpenSidebar()" title="Contents">
|
||||
<button class="icon-btn" (click)="onOpenSidebar()" [title]="'readerCbx.header.contents' | transloco">
|
||||
<app-reader-icon name="menu" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onToggleBookmark()" [title]="isCurrentPageBookmarked ? 'Remove Bookmark' : 'Add Bookmark'" [class.active]="isCurrentPageBookmarked">
|
||||
<button class="icon-btn" (click)="onToggleBookmark()" [title]="isCurrentPageBookmarked ? ('readerCbx.header.removeBookmark' | transloco) : ('readerCbx.header.addBookmark' | transloco)" [class.active]="isCurrentPageBookmarked">
|
||||
<app-reader-icon name="bookmark" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onOpenNoteDialog()" [title]="currentPageHasNotes ? 'Page has notes - Add another' : 'Add Note'" [class.has-notes]="currentPageHasNotes">
|
||||
<button class="icon-btn" (click)="onOpenNoteDialog()" [title]="currentPageHasNotes ? ('readerCbx.header.pageHasNotesAddAnother' | transloco) : ('readerCbx.header.addNote' | transloco)" [class.has-notes]="currentPageHasNotes">
|
||||
<app-reader-icon name="note" [size]="20" />
|
||||
@if (currentPageHasNotes) {
|
||||
<span class="indicator-dot"></span>
|
||||
@@ -15,17 +15,17 @@
|
||||
</div>
|
||||
<span class="book-title">{{ bookTitle }}</span>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn desktop-only" (click)="onToggleSlideshow()" [title]="state.isSlideshowActive ? 'Stop Slideshow (P)' : 'Start Slideshow (P)'" [class.active]="state.isSlideshowActive">
|
||||
<button class="icon-btn desktop-only" (click)="onToggleSlideshow()" [title]="state.isSlideshowActive ? ('readerCbx.header.stopSlideshow' | transloco) : ('readerCbx.header.startSlideshow' | transloco)" [class.active]="state.isSlideshowActive">
|
||||
<app-reader-icon [name]="state.isSlideshowActive ? 'pause' : 'play'" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn desktop-only" (click)="onToggleFullscreen()" [title]="state.isFullscreen ? 'Exit Fullscreen (F)' : 'Fullscreen (F)'">
|
||||
<button class="icon-btn desktop-only" (click)="onToggleFullscreen()" [title]="state.isFullscreen ? ('readerCbx.header.exitFullscreen' | transloco) : ('readerCbx.header.fullscreen' | transloco)">
|
||||
<app-reader-icon [name]="state.isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn desktop-only" (click)="onShowShortcutsHelp()" title="Keyboard Shortcuts (?)">
|
||||
<button class="icon-btn desktop-only" (click)="onShowShortcutsHelp()" [title]="'readerCbx.header.keyboardShortcuts' | transloco">
|
||||
<app-reader-icon name="help" [size]="20" />
|
||||
</button>
|
||||
<div class="overflow-menu mobile-only">
|
||||
<button class="icon-btn" (click)="overflowOpen = !overflowOpen; $event.stopPropagation()" title="More">
|
||||
<button class="icon-btn" (click)="overflowOpen = !overflowOpen; $event.stopPropagation()" [title]="'readerCbx.header.more' | transloco">
|
||||
<app-reader-icon name="dots-vertical" [size]="20" />
|
||||
</button>
|
||||
@if (overflowOpen) {
|
||||
@@ -33,23 +33,23 @@
|
||||
<div class="overflow-dropdown">
|
||||
<button class="overflow-item" (click)="onToggleSlideshow(); overflowOpen = false" [class.active]="state.isSlideshowActive">
|
||||
<app-reader-icon [name]="state.isSlideshowActive ? 'pause' : 'play'" [size]="18" />
|
||||
<span>{{ state.isSlideshowActive ? 'Stop Slideshow' : 'Start Slideshow' }}</span>
|
||||
<span>{{ state.isSlideshowActive ? ('readerCbx.header.stopSlideshowLabel' | transloco) : ('readerCbx.header.startSlideshowLabel' | transloco) }}</span>
|
||||
</button>
|
||||
<button class="overflow-item" (click)="onToggleFullscreen(); overflowOpen = false">
|
||||
<app-reader-icon [name]="state.isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="18" />
|
||||
<span>{{ state.isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</span>
|
||||
<span>{{ state.isFullscreen ? ('readerCbx.header.exitFullscreenLabel' | transloco) : ('readerCbx.header.fullscreenLabel' | transloco) }}</span>
|
||||
</button>
|
||||
<button class="overflow-item" (click)="onShowShortcutsHelp(); overflowOpen = false">
|
||||
<app-reader-icon name="help" [size]="18" />
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<span>{{ 'readerCbx.header.keyboardShortcutsLabel' | transloco }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button class="icon-btn" (click)="onOpenSettings()" title="Settings">
|
||||
<button class="icon-btn" (click)="onOpenSettings()" [title]="'readerCbx.header.settings' | transloco">
|
||||
<app-reader-icon name="settings" [size]="20" />
|
||||
</button>
|
||||
<button class="icon-btn close-btn" (click)="onClose(); $event.stopPropagation()" title="Close Reader">
|
||||
<button class="icon-btn close-btn" (click)="onClose(); $event.stopPropagation()" [title]="'readerCbx.header.closeReader' | transloco">
|
||||
<app-reader-icon name="close" [size]="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {TranslocoPipe} from '@jsverse/transloco';
|
||||
import {CbxHeaderService, CbxHeaderState} from './cbx-header.service';
|
||||
import {ReaderIconComponent} from '../../../ebook-reader';
|
||||
import {CommonModule} from '@angular/common';
|
||||
@@ -8,7 +9,7 @@ import {CommonModule} from '@angular/common';
|
||||
@Component({
|
||||
selector: 'app-cbx-header',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
imports: [CommonModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './cbx-header.component.html',
|
||||
styleUrls: ['./cbx-header.component.scss']
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="panel-body">
|
||||
<!-- Fit Mode -->
|
||||
<div class="control">
|
||||
<label>Fit Mode</label>
|
||||
<label>{{ 'readerCbx.quickSettings.fitMode' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<div class="button-group">
|
||||
@for (option of fitModeOptions; track option.value) {
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<!-- Scroll Mode -->
|
||||
<div class="control">
|
||||
<label>Scroll Mode</label>
|
||||
<label>{{ 'readerCbx.quickSettings.scrollMode' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<div class="button-group text-btns">
|
||||
@for (option of scrollModeOptions; track option.value) {
|
||||
@@ -41,9 +41,9 @@
|
||||
<!-- Page View (only in paginated mode) -->
|
||||
@if (isPaginated && !isPhonePortrait) {
|
||||
<div class="control">
|
||||
<label>Page View</label>
|
||||
<label>{{ 'readerCbx.quickSettings.pageView' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-label">{{ isTwoPageView ? 'Two-Page' : 'Single' }}</span>
|
||||
<span class="mode-label">{{ isTwoPageView ? ('readerCbx.quickSettings.twoPage' | transloco) : ('readerCbx.quickSettings.single' | transloco) }}</span>
|
||||
<button class="switch" [class.active]="isTwoPageView" (click)="onPageViewToggle()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
@@ -54,9 +54,9 @@
|
||||
<!-- Page Spread (only in two-page mode) -->
|
||||
@if (isPaginated && isTwoPageView) {
|
||||
<div class="control">
|
||||
<label>Page Spread</label>
|
||||
<label>{{ 'readerCbx.quickSettings.pageSpread' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-label">{{ state.pageSpread === CbxPageSpread.ODD ? 'Odd First' : 'Even First' }}</span>
|
||||
<span class="mode-label">{{ state.pageSpread === CbxPageSpread.ODD ? ('readerCbx.quickSettings.oddFirst' | transloco) : ('readerCbx.quickSettings.evenFirst' | transloco) }}</span>
|
||||
<button class="switch" [class.active]="state.pageSpread === CbxPageSpread.EVEN" (click)="onPageSpreadToggle()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
@@ -66,9 +66,9 @@
|
||||
|
||||
<!-- Reading Direction -->
|
||||
<div class="control">
|
||||
<label>Reading Direction</label>
|
||||
<label>{{ 'readerCbx.quickSettings.readingDirection' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-label">{{ state.readingDirection === CbxReadingDirection.LTR ? 'Left to Right' : 'Right to Left' }}</span>
|
||||
<span class="mode-label">{{ state.readingDirection === CbxReadingDirection.LTR ? ('readerCbx.quickSettings.leftToRight' | transloco) : ('readerCbx.quickSettings.rightToLeft' | transloco) }}</span>
|
||||
<button class="switch" [class.active]="state.readingDirection === CbxReadingDirection.RTL" (click)="onReadingDirectionToggle()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
<!-- Slideshow Interval -->
|
||||
<div class="control">
|
||||
<label>Slideshow Interval</label>
|
||||
<label>{{ 'readerCbx.quickSettings.slideshowInterval' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<div class="button-group text-btns">
|
||||
@for (option of slideshowIntervalOptions; track option.value) {
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
<!-- Background Color -->
|
||||
<div class="control">
|
||||
<label>Background</label>
|
||||
<label>{{ 'readerCbx.quickSettings.background' | transloco }}</label>
|
||||
<div class="control-right">
|
||||
<div class="button-group">
|
||||
@for (option of backgroundOptions; track option.value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoService, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {
|
||||
@@ -17,12 +18,13 @@ import {CbxQuickSettingsService, CbxQuickSettingsState} from './cbx-quick-settin
|
||||
@Component({
|
||||
selector: 'app-cbx-quick-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
imports: [CommonModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './cbx-quick-settings.component.html',
|
||||
styleUrls: ['./cbx-quick-settings.component.scss']
|
||||
})
|
||||
export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
private quickSettingsService = inject(CbxQuickSettingsService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
state: CbxQuickSettingsState = {
|
||||
@@ -43,19 +45,23 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
protected readonly CbxReadingDirection = CbxReadingDirection;
|
||||
protected readonly CbxSlideshowInterval = CbxSlideshowInterval;
|
||||
|
||||
fitModeOptions: {value: CbxFitMode, label: string, icon: ReaderIconName}[] = [
|
||||
{value: CbxFitMode.FIT_PAGE, label: 'Fit Page', icon: 'fit-page'},
|
||||
{value: CbxFitMode.FIT_WIDTH, label: 'Fit Width', icon: 'fit-width'},
|
||||
{value: CbxFitMode.FIT_HEIGHT, label: 'Fit Height', icon: 'fit-height'},
|
||||
{value: CbxFitMode.ACTUAL_SIZE, label: 'Actual Size', icon: 'actual-size'},
|
||||
{value: CbxFitMode.AUTO, label: 'Automatic', icon: 'auto-fit'}
|
||||
];
|
||||
get fitModeOptions(): {value: CbxFitMode, label: string, icon: ReaderIconName}[] {
|
||||
return [
|
||||
{value: CbxFitMode.FIT_PAGE, label: this.t.translate('readerCbx.quickSettings.fitPage'), icon: 'fit-page'},
|
||||
{value: CbxFitMode.FIT_WIDTH, label: this.t.translate('readerCbx.quickSettings.fitWidth'), icon: 'fit-width'},
|
||||
{value: CbxFitMode.FIT_HEIGHT, label: this.t.translate('readerCbx.quickSettings.fitHeight'), icon: 'fit-height'},
|
||||
{value: CbxFitMode.ACTUAL_SIZE, label: this.t.translate('readerCbx.quickSettings.actualSize'), icon: 'actual-size'},
|
||||
{value: CbxFitMode.AUTO, label: this.t.translate('readerCbx.quickSettings.automatic'), icon: 'auto-fit'}
|
||||
];
|
||||
}
|
||||
|
||||
scrollModeOptions: {value: CbxScrollMode, label: string}[] = [
|
||||
{value: CbxScrollMode.PAGINATED, label: 'Paginated'},
|
||||
{value: CbxScrollMode.INFINITE, label: 'Infinite'},
|
||||
{value: CbxScrollMode.LONG_STRIP, label: 'Long Strip'}
|
||||
];
|
||||
get scrollModeOptions(): {value: CbxScrollMode, label: string}[] {
|
||||
return [
|
||||
{value: CbxScrollMode.PAGINATED, label: this.t.translate('readerCbx.quickSettings.paginated')},
|
||||
{value: CbxScrollMode.INFINITE, label: this.t.translate('readerCbx.quickSettings.infinite')},
|
||||
{value: CbxScrollMode.LONG_STRIP, label: this.t.translate('readerCbx.quickSettings.longStrip')}
|
||||
];
|
||||
}
|
||||
|
||||
slideshowIntervalOptions: {value: CbxSlideshowInterval, label: string}[] = [
|
||||
{value: CbxSlideshowInterval.THREE_SECONDS, label: '3s'},
|
||||
@@ -65,11 +71,13 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
{value: CbxSlideshowInterval.THIRTY_SECONDS, label: '30s'}
|
||||
];
|
||||
|
||||
backgroundOptions = [
|
||||
{value: CbxBackgroundColor.BLACK, label: 'Black', color: '#000000'},
|
||||
{value: CbxBackgroundColor.GRAY, label: 'Gray', color: '#808080'},
|
||||
{value: CbxBackgroundColor.WHITE, label: 'White', color: '#ffffff'}
|
||||
];
|
||||
get backgroundOptions() {
|
||||
return [
|
||||
{value: CbxBackgroundColor.BLACK, label: this.t.translate('readerCbx.quickSettings.black'), color: '#000000'},
|
||||
{value: CbxBackgroundColor.GRAY, label: this.t.translate('readerCbx.quickSettings.gray'), color: '#808080'},
|
||||
{value: CbxBackgroundColor.WHITE, label: this.t.translate('readerCbx.quickSettings.white'), color: '#ffffff'}
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.quickSettingsService.state$
|
||||
@@ -99,7 +107,7 @@ export class CbxQuickSettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get currentScrollModeLabel(): string {
|
||||
return this.scrollModeOptions.find(o => o.value === this.state.scrollMode)?.label || 'Paginated';
|
||||
return this.scrollModeOptions.find(o => o.value === this.state.scrollMode)?.label || this.t.translate('readerCbx.quickSettings.paginated');
|
||||
}
|
||||
|
||||
get currentSlideshowIntervalLabel(): string {
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
[class.active]="activeTab === 'pages'"
|
||||
(click)="setActiveTab('pages')">
|
||||
<app-reader-icon name="book" class="tab-icon"></app-reader-icon>
|
||||
<span>Content</span>
|
||||
<span>{{ 'readerCbx.sidebar.contentTab' | transloco }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'bookmarks'"
|
||||
(click)="setActiveTab('bookmarks')">
|
||||
<app-reader-icon name="bookmark" class="tab-icon"></app-reader-icon>
|
||||
<span>Bookmarks</span>
|
||||
<span>{{ 'readerCbx.sidebar.bookmarksTab' | transloco }}</span>
|
||||
@if (bookmarks.length > 0) {
|
||||
<span class="badge">{{ bookmarks.length }}</span>
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
[class.active]="activeTab === 'notes'"
|
||||
(click)="setActiveTab('notes')">
|
||||
<app-reader-icon name="note" class="tab-icon"></app-reader-icon>
|
||||
<span>Notes</span>
|
||||
<span>{{ 'readerCbx.sidebar.notesTab' | transloco }}</span>
|
||||
@if (notes.length > 0) {
|
||||
<span class="badge">{{ notes.length }}</span>
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
@if (pages.length === 0) {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="book" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No pages found</p>
|
||||
<p>{{ 'readerCbx.sidebar.noPagesFound' | transloco }}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
<span class="item-title">{{ bookmark.title }}</span>
|
||||
<span class="item-meta">{{ bookmark.createdAt | date: 'MMM d, y' }}</span>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="onDeleteBookmark($event, bookmark.id)" aria-label="Delete bookmark">
|
||||
<button class="delete-btn" (click)="onDeleteBookmark($event, bookmark.id)" [attr.aria-label]="'readerCbx.sidebar.deleteBookmark' | transloco">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -88,8 +88,8 @@
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="bookmark" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No bookmarks yet</p>
|
||||
<span class="empty-hint">Tap the bookmark icon to save your place</span>
|
||||
<p>{{ 'readerCbx.sidebar.noBookmarksYet' | transloco }}</p>
|
||||
<span class="empty-hint">{{ 'readerCbx.sidebar.bookmarkHint' | transloco }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -101,12 +101,12 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search notes..."
|
||||
[placeholder]="'readerCbx.sidebar.searchNotesPlaceholder' | transloco"
|
||||
[(ngModel)]="notesSearchQuery"
|
||||
(ngModelChange)="onNotesSearchInput($event)"
|
||||
/>
|
||||
@if (notesSearchQuery) {
|
||||
<button class="clear-btn" (click)="clearNotesSearch()" aria-label="Clear search">
|
||||
<button class="clear-btn" (click)="clearNotesSearch()" [attr.aria-label]="'readerCbx.sidebar.clearSearch' | transloco">
|
||||
<app-reader-icon name="close" [size]="14"></app-reader-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -126,10 +126,10 @@
|
||||
<span class="item-meta">{{ note.createdAt | date: 'MMM d, y' }}</span>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="edit-btn" (click)="onEditNote($event, note)" aria-label="Edit note">
|
||||
<button class="edit-btn" (click)="onEditNote($event, note)" [attr.aria-label]="'readerCbx.sidebar.editNote' | transloco">
|
||||
<app-reader-icon name="edit" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
<button class="delete-btn" (click)="onDeleteNote($event, note.id)" aria-label="Delete note">
|
||||
<button class="delete-btn" (click)="onDeleteNote($event, note.id)" [attr.aria-label]="'readerCbx.sidebar.deleteNote' | transloco">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -139,14 +139,14 @@
|
||||
} @else if (notes.length > 0 && notesSearchQuery) {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="search" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No matching notes</p>
|
||||
<span class="empty-hint">Try different search terms</span>
|
||||
<p>{{ 'readerCbx.sidebar.noMatchingNotes' | transloco }}</p>
|
||||
<span class="empty-hint">{{ 'readerCbx.sidebar.tryDifferentSearch' | transloco }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="note" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No notes yet</p>
|
||||
<span class="empty-hint">Tap the notes icon to add a note for the current page</span>
|
||||
<p>{{ 'readerCbx.sidebar.noNotesYet' | transloco }}</p>
|
||||
<span class="empty-hint">{{ 'readerCbx.sidebar.notesHint' | transloco }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {CommonModule, DatePipe} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {TranslocoPipe} from '@jsverse/transloco';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {CbxSidebarService, CbxSidebarTab, SidebarBookInfo} from './cbx-sidebar.service';
|
||||
@@ -14,7 +15,7 @@ import {ReaderIconComponent} from '../../../ebook-reader';
|
||||
standalone: true,
|
||||
templateUrl: './cbx-sidebar.component.html',
|
||||
styleUrls: ['./cbx-sidebar.component.scss'],
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent, DatePipe]
|
||||
imports: [CommonModule, FormsModule, TranslocoPipe, ReaderIconComponent, DatePipe]
|
||||
})
|
||||
export class CbxSidebarComponent implements OnInit, OnDestroy {
|
||||
private sidebarService = inject(CbxSidebarService);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {BehaviorSubject, Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {CbxPageInfo, CbxReaderService} from '../../../../book/service/cbx-reader.service';
|
||||
import {UrlHelperService} from '../../../../../shared/service/url-helper.service';
|
||||
import {Book} from '../../../../book/model/book.model';
|
||||
@@ -22,6 +23,7 @@ export class CbxSidebarService {
|
||||
private cbxReaderService = inject(CbxReaderService);
|
||||
private bookMarkService = inject(BookMarkService);
|
||||
private bookNoteV2Service = inject(BookNoteV2Service);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private bookId!: number;
|
||||
@@ -63,7 +65,7 @@ export class CbxSidebarService {
|
||||
|
||||
this._bookInfo.next({
|
||||
id: book.id,
|
||||
title: book.metadata?.title || book.fileName || 'Untitled',
|
||||
title: book.metadata?.title || book.fileName || this.t.translate('readerCbx.sidebar.untitled'),
|
||||
authors: (book.metadata?.authors || []).join(', '),
|
||||
coverUrl: this.urlHelper.getThumbnailUrl(book.id, book.metadata?.coverUpdatedOn)
|
||||
});
|
||||
@@ -129,7 +131,7 @@ export class CbxSidebarService {
|
||||
const request: CreateBookMarkRequest = {
|
||||
bookId: this.bookId,
|
||||
cfi: pageNumber.toString(),
|
||||
title: title || `Page ${pageNumber}`
|
||||
title: title || `${this.t.translate('readerCbx.sidebar.page')} ${pageNumber}`
|
||||
};
|
||||
|
||||
this.bookMarkService.createBookmark(request)
|
||||
@@ -177,7 +179,7 @@ export class CbxSidebarService {
|
||||
cfi: pageNumber.toString(),
|
||||
noteContent,
|
||||
color: color || '#FFC107',
|
||||
chapterTitle: `Page ${pageNumber}`
|
||||
chapterTitle: `${this.t.translate('readerCbx.sidebar.page')} ${pageNumber}`
|
||||
};
|
||||
|
||||
this.bookNoteV2Service.createNote(request)
|
||||
|
||||
@@ -184,9 +184,9 @@ export class ReaderViewManagerService {
|
||||
this.annotationService.addAnnotations(this.view, annotations);
|
||||
}
|
||||
|
||||
updateHeadersAndFooters(chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo): void {
|
||||
updateHeadersAndFooters(chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo, timeRemainingLabel?: string): void {
|
||||
const renderer = this.getRenderer();
|
||||
PageDecorator.updateHeadersAndFooters(renderer, chapterName, pageInfo, theme);
|
||||
PageDecorator.updateHeadersAndFooters(renderer, chapterName, pageInfo, theme, timeRemainingLabel);
|
||||
}
|
||||
|
||||
getChapters(): TocItem[] {
|
||||
|
||||
@@ -1,67 +1,68 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.metadataDialog'">
|
||||
<div class="dialog-overlay" (click)="close.emit()">
|
||||
<div class="dialog" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-header">
|
||||
<h2>Book Information</h2>
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
<h2>{{ t('title') }}</h2>
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="metadata-container">
|
||||
<div class="cover-section">
|
||||
@if (bookCoverUrl) {
|
||||
<img [src]="bookCoverUrl" [alt]="metadata?.title || 'Book cover'" class="book-cover">
|
||||
<img [src]="bookCoverUrl" [alt]="metadata?.title || t('unknown')" class="book-cover">
|
||||
} @else {
|
||||
<div class="cover-placeholder">
|
||||
<span class="placeholder-icon">📚</span>
|
||||
<span class="placeholder-icon">📚</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="metadata-section">
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Basic Information</h3>
|
||||
<h3 class="section-title">{{ t('basicInformation') }}</h3>
|
||||
|
||||
<div class="metadata-item">
|
||||
<span class="label">Title</span>
|
||||
<span class="value">{{ metadata?.title || 'Unknown' }}</span>
|
||||
<span class="label">{{ t('titleLabel') }}</span>
|
||||
<span class="value">{{ metadata?.title || t('unknown') }}</span>
|
||||
</div>
|
||||
|
||||
@if (metadata?.subtitle) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Subtitle</span>
|
||||
<span class="label">{{ t('subtitle') }}</span>
|
||||
<span class="value">{{ metadata?.subtitle }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="metadata-item">
|
||||
<span class="label">Author(s)</span>
|
||||
<span class="label">{{ t('authors') }}</span>
|
||||
<span class="value">{{ formatAuthors(metadata?.authors) }}</span>
|
||||
</div>
|
||||
|
||||
@if (metadata?.publisher) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Publisher</span>
|
||||
<span class="label">{{ t('publisher') }}</span>
|
||||
<span class="value">{{ metadata?.publisher }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (metadata?.publishedDate) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Published</span>
|
||||
<span class="label">{{ t('published') }}</span>
|
||||
<span class="value">{{ formatDate(metadata?.publishedDate) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (metadata?.language) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Language</span>
|
||||
<span class="label">{{ t('language') }}</span>
|
||||
<span class="value">{{ metadata?.language }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (metadata?.pageCount) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Pages</span>
|
||||
<span class="label">{{ t('pages') }}</span>
|
||||
<span class="value">{{ metadata?.pageCount }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -69,16 +70,16 @@
|
||||
|
||||
@if (metadata?.seriesName) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Series</h3>
|
||||
<h3 class="section-title">{{ t('series') }}</h3>
|
||||
|
||||
<div class="metadata-item">
|
||||
<span class="label">Series Name</span>
|
||||
<span class="label">{{ t('seriesName') }}</span>
|
||||
<span class="value">{{ metadata?.seriesName }}</span>
|
||||
</div>
|
||||
|
||||
@if (metadata?.seriesNumber) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Book Number</span>
|
||||
<span class="label">{{ t('bookNumber') }}</span>
|
||||
<span class="value">
|
||||
{{ metadata?.seriesNumber }}@if (metadata?.seriesTotal) {
|
||||
/ {{ metadata?.seriesTotal }}
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
@if (metadata?.isbn13 || metadata?.isbn10 || metadata?.asin) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Identifiers</h3>
|
||||
<h3 class="section-title">{{ t('identifiers') }}</h3>
|
||||
|
||||
@if (metadata?.isbn13) {
|
||||
<div class="metadata-item">
|
||||
@@ -118,15 +119,15 @@
|
||||
|
||||
@if (metadata?.rating || metadata?.goodreadsRating || metadata?.amazonRating) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Ratings</h3>
|
||||
<h3 class="section-title">{{ t('ratings') }}</h3>
|
||||
|
||||
@if (metadata?.goodreadsRating) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">Goodreads</span>
|
||||
<span class="value">
|
||||
⭐ {{ metadata?.goodreadsRating?.toFixed(2) }}
|
||||
⭐ {{ metadata?.goodreadsRating?.toFixed(2) }}
|
||||
@if (metadata?.goodreadsReviewCount) {
|
||||
<span class="review-count">({{ metadata?.goodreadsReviewCount }} reviews)</span>
|
||||
<span class="review-count">({{ metadata?.goodreadsReviewCount }} {{ t('reviews') }})</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -136,9 +137,9 @@
|
||||
<div class="metadata-item">
|
||||
<span class="label">Amazon</span>
|
||||
<span class="value">
|
||||
⭐ {{ metadata?.amazonRating?.toFixed(2) }}
|
||||
⭐ {{ metadata?.amazonRating?.toFixed(2) }}
|
||||
@if (metadata?.amazonReviewCount) {
|
||||
<span class="review-count">({{ metadata?.amazonReviewCount }} reviews)</span>
|
||||
<span class="review-count">({{ metadata?.amazonReviewCount }} {{ t('reviews') }})</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@@ -148,7 +149,7 @@
|
||||
|
||||
@if (metadata?.categories && metadata?.categories!.length > 0) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Categories</h3>
|
||||
<h3 class="section-title">{{ t('categories') }}</h3>
|
||||
<div class="tags-container">
|
||||
@for (category of metadata?.categories; track category) {
|
||||
<span class="tag">{{ category }}</span>
|
||||
@@ -159,7 +160,7 @@
|
||||
|
||||
@if (metadata?.tags && metadata?.tags!.length > 0) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Tags</h3>
|
||||
<h3 class="section-title">{{ t('tags') }}</h3>
|
||||
<div class="tags-container">
|
||||
@for (tag of metadata?.tags; track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
@@ -170,16 +171,16 @@
|
||||
|
||||
@if (book?.fileSizeKb) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">File Information</h3>
|
||||
<h3 class="section-title">{{ t('fileInformation') }}</h3>
|
||||
|
||||
<div class="metadata-item">
|
||||
<span class="label">File Size</span>
|
||||
<span class="label">{{ t('fileSize') }}</span>
|
||||
<span class="value">{{ formatFileSize(book?.fileSizeKb) }}</span>
|
||||
</div>
|
||||
|
||||
@if (book?.fileName) {
|
||||
<div class="metadata-item">
|
||||
<span class="label">File Name</span>
|
||||
<span class="label">{{ t('fileName') }}</span>
|
||||
<span class="value file-name">{{ book?.fileName }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -188,7 +189,7 @@
|
||||
|
||||
@if (metadata?.description) {
|
||||
<div class="metadata-group">
|
||||
<h3 class="section-title">Description</h3>
|
||||
<h3 class="section-title">{{ t('description') }}</h3>
|
||||
<p class="description">{{ metadata?.description }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -197,3 +198,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {Component, EventEmitter, inject, Input, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {Book} from '../../../book/model/book.model';
|
||||
import {UrlHelperService} from '../../../../shared/service/url-helper.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-reader-book-metadata-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, TranslocoDirective],
|
||||
templateUrl: './metadata-dialog.component.html',
|
||||
styleUrls: ['./metadata-dialog.component.scss']
|
||||
})
|
||||
@@ -15,6 +17,7 @@ export class ReaderBookMetadataDialogComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
private urlHelperService = inject(UrlHelperService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
get metadata() {
|
||||
return this.book?.metadata;
|
||||
@@ -27,7 +30,7 @@ export class ReaderBookMetadataDialogComponent {
|
||||
}
|
||||
|
||||
formatDate(date: string | undefined): string {
|
||||
if (!date) return 'N/A';
|
||||
if (!date) return this.t.translate('readerEbook.metadataDialog.na');
|
||||
try {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -40,12 +43,12 @@ export class ReaderBookMetadataDialogComponent {
|
||||
}
|
||||
|
||||
formatAuthors(authors: string[] | undefined): string {
|
||||
if (!authors || authors.length === 0) return 'Unknown';
|
||||
if (!authors || authors.length === 0) return this.t.translate('readerEbook.metadataDialog.unknown');
|
||||
return authors.join(', ');
|
||||
}
|
||||
|
||||
formatFileSize(sizeKb: number | undefined): string {
|
||||
if (!sizeKb) return 'N/A';
|
||||
if (!sizeKb) return this.t.translate('readerEbook.metadataDialog.na');
|
||||
if (sizeKb < 1024) return `${sizeKb.toFixed(1)} KB`;
|
||||
return `${(sizeKb / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.noteDialog'">
|
||||
<div class="dialog-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>{{ isEditing ? 'Edit Note' : 'Add Note' }}</h2>
|
||||
<h2>{{ isEditing ? t('editNote') : t('addNote') }}</h2>
|
||||
<button class="close-btn" (click)="onCancel()">
|
||||
<app-reader-icon name="close" [size]="18"></app-reader-icon>
|
||||
</button>
|
||||
@@ -10,25 +11,25 @@
|
||||
<div class="dialog-body">
|
||||
@if (data?.selectedText) {
|
||||
<div class="selected-text-section">
|
||||
<span class="section-label">Selected Text</span>
|
||||
<span class="section-label">{{ t('selectedText') }}</span>
|
||||
<p class="selected-text">"{{ data?.selectedText }}"</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="note-section">
|
||||
<label class="section-label" for="noteContent">Your Note</label>
|
||||
<label class="section-label" for="noteContent">{{ t('yourNote') }}</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
class="note-input"
|
||||
[(ngModel)]="noteContent"
|
||||
placeholder="Write your note here..."
|
||||
[placeholder]="t('notePlaceholder')"
|
||||
rows="6"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="color-section">
|
||||
<span class="section-label">Note Color</span>
|
||||
<span class="section-label">{{ t('noteColor') }}</span>
|
||||
<div class="color-buttons">
|
||||
@for (color of noteColors; track color.value) {
|
||||
<button
|
||||
@@ -44,8 +45,9 @@
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-secondary" (click)="onCancel()">Cancel</button>
|
||||
<button class="btn btn-primary" (click)="onSave()" [disabled]="!noteContent.trim()">{{ isEditing ? 'Update Note' : 'Save Note' }}</button>
|
||||
<button class="btn btn-secondary" (click)="onCancel()">{{ 'common.cancel' | transloco }}</button>
|
||||
<button class="btn btn-primary" (click)="onSave()" [disabled]="!noteContent.trim()">{{ isEditing ? t('updateNote') : t('saveNote') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, EventEmitter, Input, Output, OnChanges, SimpleChanges} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {TranslocoDirective, TranslocoPipe} from '@jsverse/transloco';
|
||||
import {ReaderIconComponent} from '../shared/icon.component';
|
||||
|
||||
export interface NoteDialogData {
|
||||
@@ -20,7 +21,7 @@ export interface NoteDialogResult {
|
||||
@Component({
|
||||
selector: 'app-reader-note-dialog',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent],
|
||||
imports: [CommonModule, FormsModule, TranslocoDirective, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './note-dialog.component.html',
|
||||
styleUrls: ['./note-dialog.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.settingsDialog'">
|
||||
<div class="dialog-overlay" (click)="close.emit()">
|
||||
<div class="dialog" (click)="$event.stopPropagation()">
|
||||
<div class="tabs-row">
|
||||
@@ -6,39 +7,39 @@
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'theme'"
|
||||
(click)="activeTab = 'theme'">
|
||||
Theme
|
||||
{{ t('themeTab') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'typography'"
|
||||
(click)="activeTab = 'typography'">
|
||||
Typography
|
||||
{{ t('typographyTab') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'layout'"
|
||||
(click)="activeTab = 'layout'">
|
||||
Layout
|
||||
{{ t('layoutTab') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
@if (activeTab === 'theme') {
|
||||
<div class="tab-content">
|
||||
<div class="section-header dark-mode-header">
|
||||
Dark Mode
|
||||
{{ t('darkMode') }}
|
||||
<span class="mode-switch-wrapper">
|
||||
<span class="mode-icon sun" [class.active]="!state.isDark">☀️</span>
|
||||
<span class="mode-icon sun" [class.active]="!state.isDark">☀️</span>
|
||||
<button class="switch" [class.active]="state.isDark" (click)="toggleDarkMode()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
<span class="mode-icon moon" [class.active]="state.isDark">🌙</span>
|
||||
<span class="mode-icon moon" [class.active]="state.isDark">🌙</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-header">Theme Colors</div>
|
||||
<div class="section-header">{{ t('themeColors') }}</div>
|
||||
<div class="theme-grid">
|
||||
@for (theme of themes; track theme.name) {
|
||||
<label class="theme-option">
|
||||
@@ -59,7 +60,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="section-header">Annotation Highlighter</div>
|
||||
<div class="section-header">{{ t('annotationHighlighter') }}</div>
|
||||
<div class="annotation-colors">
|
||||
@for (color of annotationColors; track color.name) {
|
||||
<button
|
||||
@@ -69,7 +70,7 @@
|
||||
[attr.aria-label]="color.label"
|
||||
(click)="setAnnotationColor(color.value)">
|
||||
@if (selectedAnnotationColor === color.value) {
|
||||
<span class="check-icon">✓</span>
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@@ -80,29 +81,29 @@
|
||||
@if (activeTab === 'typography') {
|
||||
<div class="tab-content">
|
||||
|
||||
<div class="section-header">Font Settings</div>
|
||||
<div class="section-header">{{ t('fontSettings') }}</div>
|
||||
<div class="control">
|
||||
<label>Font Size</label>
|
||||
<label>{{ t('fontSize') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseFontSize()">−</button>
|
||||
<button (click)="decreaseFontSize()">−</button>
|
||||
<span>{{ state.fontSize }}</span>
|
||||
<button (click)="increaseFontSize()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label>Line Height</label>
|
||||
<label>{{ t('lineHeight') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseLineHeight()">−</button>
|
||||
<button (click)="decreaseLineHeight()">−</button>
|
||||
<span>{{ state.lineHeight | number:'1.1-1' }}</span>
|
||||
<button (click)="increaseLineHeight()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header">Font Family</div>
|
||||
<div class="section-header">{{ t('fontFamily') }}</div>
|
||||
<div class="font-grid">
|
||||
@for (font of fonts; track font.value) {
|
||||
<button
|
||||
@@ -120,10 +121,10 @@
|
||||
|
||||
@if (activeTab === 'layout') {
|
||||
<div class="tab-content">
|
||||
<div class="section-header">Layout</div>
|
||||
<div class="section-header">{{ t('layout') }}</div>
|
||||
|
||||
<div class="control">
|
||||
<label>Reading Flow</label>
|
||||
<label>{{ t('readingFlow') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
@@ -134,7 +135,7 @@
|
||||
[checked]="state.flow === 'paginated'"
|
||||
(change)="setFlow('paginated')"
|
||||
/>
|
||||
Paginated
|
||||
{{ t('paginated') }}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
@@ -144,17 +145,17 @@
|
||||
[checked]="state.flow === 'scrolled'"
|
||||
(change)="setFlow('scrolled')"
|
||||
/>
|
||||
Scrolled
|
||||
{{ t('scrolled') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<label>Max Columns</label>
|
||||
<label>{{ t('maxColumns') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseMaxColumnCount()">−</button>
|
||||
<button (click)="decreaseMaxColumnCount()">−</button>
|
||||
<span>{{ state.maxColumnCount }}</span>
|
||||
<button (click)="increaseMaxColumnCount()">+</button>
|
||||
</div>
|
||||
@@ -162,7 +163,7 @@
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<label>Column Gap</label>
|
||||
<label>{{ t('columnGap') }}</label>
|
||||
<div class="control-right gap-control">
|
||||
<input
|
||||
#gapInput
|
||||
@@ -178,10 +179,10 @@
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<label>Max Width</label>
|
||||
<label>{{ t('maxWidth') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseMaxInlineSize()">−</button>
|
||||
<button (click)="decreaseMaxInlineSize()">−</button>
|
||||
<span>{{ state.maxInlineSize }}</span>
|
||||
<button (click)="increaseMaxInlineSize()">+</button>
|
||||
</div>
|
||||
@@ -189,27 +190,27 @@
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<label>Max Height</label>
|
||||
<label>{{ t('maxHeight') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseMaxBlockSize()">−</button>
|
||||
<button (click)="decreaseMaxBlockSize()">−</button>
|
||||
<span>{{ state.maxBlockSize }}</span>
|
||||
<button (click)="increaseMaxBlockSize()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header">Text Options</div>
|
||||
<div class="section-header">{{ t('textOptions') }}</div>
|
||||
|
||||
<div class="toggle-control">
|
||||
<span>Justify Text</span>
|
||||
<span>{{ t('justifyText') }}</span>
|
||||
<button class="switch" [class.active]="state.justify" (click)="toggleJustify()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toggle-control">
|
||||
<span>Hyphenate</span>
|
||||
<span>{{ t('hyphenate') }}</span>
|
||||
<button class="switch" [class.active]="state.hyphenate" (click)="toggleHyphenate()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
@@ -219,3 +220,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, EventEmitter, Inject, Input, OnInit, Output, Renderer2} from '@angular/core';
|
||||
import {DecimalPipe, DOCUMENT} from '@angular/common';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderStateService} from '../state/reader-state.service';
|
||||
import {ReaderViewManagerService} from '../core/view-manager.service';
|
||||
import {BookService} from '../../../book/service/book.service';
|
||||
@@ -15,7 +16,7 @@ interface AnnotationColor {
|
||||
@Component({
|
||||
selector: 'app-settings-dialog',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe],
|
||||
imports: [DecimalPipe, TranslocoDirective],
|
||||
templateUrl: './settings-dialog.component.html',
|
||||
styleUrls: ['./settings-dialog.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="dialog-overlay" (click)="onOverlayClick($event)">
|
||||
<div class="dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<h2>{{ 'readerEbook.shortcutsHelp.title' | transloco }}</h2>
|
||||
<button class="close-btn" (click)="onClose()">
|
||||
<app-reader-icon name="close" [size]="18"></app-reader-icon>
|
||||
</button>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-primary" (click)="onClose()">Got it</button>
|
||||
<button class="btn btn-primary" (click)="onClose()">{{ 'readerEbook.shortcutsHelp.gotIt' | transloco }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, EventEmitter, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoPipe, TranslocoService} from '@jsverse/transloco';
|
||||
import {ReaderIconComponent} from '../shared/icon.component';
|
||||
|
||||
interface ShortcutItem {
|
||||
@@ -16,49 +17,53 @@ interface ShortcutGroup {
|
||||
@Component({
|
||||
selector: 'app-ebook-shortcuts-help',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
imports: [CommonModule, TranslocoPipe, ReaderIconComponent],
|
||||
templateUrl: './shortcuts-help.component.html',
|
||||
styleUrls: ['./shortcuts-help.component.scss']
|
||||
})
|
||||
export class EbookShortcutsHelpComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{keys: ['←'], description: 'Previous page', mobileGesture: 'Swipe right'},
|
||||
{keys: ['→'], description: 'Next page', mobileGesture: 'Swipe left'},
|
||||
{keys: ['Space'], description: 'Next page'},
|
||||
{keys: ['Shift', 'Space'], description: 'Previous page'},
|
||||
{keys: ['Home'], description: 'First section'},
|
||||
{keys: ['End'], description: 'Last section'},
|
||||
{keys: ['Page Up'], description: 'Previous page'},
|
||||
{keys: ['Page Down'], description: 'Next page'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Panels',
|
||||
shortcuts: [
|
||||
{keys: ['T'], description: 'Table of contents'},
|
||||
{keys: ['S'], description: 'Search'},
|
||||
{keys: ['N'], description: 'Notes'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: 'Toggle fullscreen'},
|
||||
{keys: ['Escape'], description: 'Exit fullscreen / Close dialogs'}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
shortcuts: [
|
||||
{keys: ['?'], description: 'Show this help dialog'}
|
||||
]
|
||||
}
|
||||
];
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
get shortcutGroups(): ShortcutGroup[] {
|
||||
return [
|
||||
{
|
||||
title: this.t.translate('readerEbook.shortcutsHelp.navigation'),
|
||||
shortcuts: [
|
||||
{keys: ['\u2190'], description: this.t.translate('readerEbook.shortcutsHelp.previousPage'), mobileGesture: this.t.translate('readerEbook.shortcutsHelp.swipeRight')},
|
||||
{keys: ['\u2192'], description: this.t.translate('readerEbook.shortcutsHelp.nextPage'), mobileGesture: this.t.translate('readerEbook.shortcutsHelp.swipeLeft')},
|
||||
{keys: ['Space'], description: this.t.translate('readerEbook.shortcutsHelp.nextPage')},
|
||||
{keys: ['Shift', 'Space'], description: this.t.translate('readerEbook.shortcutsHelp.previousPage')},
|
||||
{keys: ['Home'], description: this.t.translate('readerEbook.shortcutsHelp.firstSection')},
|
||||
{keys: ['End'], description: this.t.translate('readerEbook.shortcutsHelp.lastSection')},
|
||||
{keys: ['Page Up'], description: this.t.translate('readerEbook.shortcutsHelp.previousPage')},
|
||||
{keys: ['Page Down'], description: this.t.translate('readerEbook.shortcutsHelp.nextPage')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerEbook.shortcutsHelp.panels'),
|
||||
shortcuts: [
|
||||
{keys: ['T'], description: this.t.translate('readerEbook.shortcutsHelp.tableOfContents')},
|
||||
{keys: ['S'], description: this.t.translate('readerEbook.shortcutsHelp.searchShortcut')},
|
||||
{keys: ['N'], description: this.t.translate('readerEbook.shortcutsHelp.notesShortcut')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerEbook.shortcutsHelp.display'),
|
||||
shortcuts: [
|
||||
{keys: ['F'], description: this.t.translate('readerEbook.shortcutsHelp.toggleFullscreen')},
|
||||
{keys: ['Escape'], description: this.t.translate('readerEbook.shortcutsHelp.exitFullscreenCloseDialogs')}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: this.t.translate('readerEbook.shortcutsHelp.other'),
|
||||
shortcuts: [
|
||||
{keys: ['?'], description: this.t.translate('readerEbook.shortcutsHelp.showHelpDialog')}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
isMobile = window.innerWidth < 768;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="loader-overlay">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
<p class="loader-text">Loading book...</p>
|
||||
<p class="loader-text">{{ 'readerEbook.reader.loadingBook' | transloco }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {EpubCustomFontService} from './features/fonts/custom-font.service';
|
||||
import {TextSelectionAction, TextSelectionPopupComponent} from './shared/selection-popup.component';
|
||||
import {NoteDialogData, NoteDialogResult, ReaderNoteDialogComponent} from './dialogs/note-dialog.component';
|
||||
import {EbookShortcutsHelpComponent} from './dialogs/shortcuts-help.component';
|
||||
import {TranslocoPipe} from '@jsverse/transloco';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ebook-reader',
|
||||
@@ -45,7 +46,8 @@ import {EbookShortcutsHelpComponent} from './dialogs/shortcuts-help.component';
|
||||
ReaderNavbarComponent,
|
||||
TextSelectionPopupComponent,
|
||||
ReaderNoteDialogComponent,
|
||||
EbookShortcutsHelpComponent
|
||||
EbookShortcutsHelpComponent,
|
||||
TranslocoPipe
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
providers: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import {inject, Injectable} from '@angular/core';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {catchError, map, tap} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {
|
||||
Annotation,
|
||||
AnnotationService,
|
||||
@@ -14,6 +15,7 @@ import {Annotation as ViewAnnotation, ReaderAnnotationService} from './annotatio
|
||||
export class ReaderAnnotationHttpService {
|
||||
private annotationService = inject(AnnotationService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private readerAnnotationService = inject(ReaderAnnotationService);
|
||||
|
||||
private currentChapterTitle: string | null = null;
|
||||
@@ -44,8 +46,8 @@ export class ReaderAnnotationHttpService {
|
||||
tap(() => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Highlight Added',
|
||||
detail: 'Your highlight was saved successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.highlightAddedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.highlightAddedDetail')
|
||||
});
|
||||
}),
|
||||
catchError(error => {
|
||||
@@ -54,13 +56,13 @@ export class ReaderAnnotationHttpService {
|
||||
isDuplicate
|
||||
? {
|
||||
severity: 'warn',
|
||||
summary: 'Highlight Already Exists',
|
||||
detail: 'You already have a highlight at this location.'
|
||||
summary: this.t.translate('readerEbook.toast.highlightExistsSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.highlightExistsDetail')
|
||||
}
|
||||
: {
|
||||
severity: 'error',
|
||||
summary: 'Unable to Add Highlight',
|
||||
detail: 'Something went wrong while adding the highlight. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.highlightFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.highlightFailedDetail')
|
||||
}
|
||||
);
|
||||
return of(null);
|
||||
@@ -81,16 +83,16 @@ export class ReaderAnnotationHttpService {
|
||||
map(() => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Highlight Removed',
|
||||
detail: 'Your highlight was removed successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.highlightRemovedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.highlightRemovedDetail')
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
catchError(() => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to Remove Highlight',
|
||||
detail: 'Something went wrong while removing the highlight. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.highlightRemoveFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.highlightRemoveFailedDetail')
|
||||
});
|
||||
return of(false);
|
||||
})
|
||||
@@ -102,15 +104,15 @@ export class ReaderAnnotationHttpService {
|
||||
tap(() => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Note Updated',
|
||||
detail: 'Your note was saved successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.noteAnnotationUpdatedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.noteAnnotationUpdatedDetail')
|
||||
});
|
||||
}),
|
||||
catchError(() => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to Update Note',
|
||||
detail: 'Something went wrong while updating the note. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.noteAnnotationUpdateFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.noteAnnotationUpdateFailedDetail')
|
||||
});
|
||||
return of(null);
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Observable, of} from 'rxjs';
|
||||
import {catchError, map} from 'rxjs/operators';
|
||||
import {BookMarkService} from '../../../../../shared/service/book-mark.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Injectable()
|
||||
export class ReaderBookmarkService {
|
||||
@@ -11,6 +12,7 @@ export class ReaderBookmarkService {
|
||||
|
||||
private bookMarkService = inject(BookMarkService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
|
||||
updateCurrentPosition(cfi: string, chapterName?: string): void {
|
||||
@@ -32,8 +34,8 @@ export class ReaderBookmarkService {
|
||||
map(() => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Bookmark Added',
|
||||
detail: 'Your bookmark was added successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.bookmarkAddedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.bookmarkAddedDetail')
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
@@ -43,13 +45,13 @@ export class ReaderBookmarkService {
|
||||
isDuplicate
|
||||
? {
|
||||
severity: 'warn',
|
||||
summary: 'Bookmark Already Exists',
|
||||
detail: 'You already have a bookmark at this location.'
|
||||
summary: this.t.translate('readerEbook.toast.bookmarkExistsSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.bookmarkExistsDetail')
|
||||
}
|
||||
: {
|
||||
severity: 'error',
|
||||
summary: 'Unable to Add Bookmark',
|
||||
detail: 'Something went wrong while adding the bookmark. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.bookmarkFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.bookmarkFailedDetail')
|
||||
}
|
||||
);
|
||||
return of(false);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {inject, Injectable} from '@angular/core';
|
||||
import {BehaviorSubject, Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {BookNoteV2Service, CreateBookNoteV2Request, UpdateBookNoteV2Request} from '../../../../../shared/service/book-note-v2.service';
|
||||
import {NoteDialogData, NoteDialogResult} from '../../dialogs/note-dialog.component';
|
||||
import {ReaderSelectionService} from '../selection/selection.service';
|
||||
@@ -18,6 +19,7 @@ export interface NoteDialogState {
|
||||
export class ReaderNoteService {
|
||||
private bookNoteV2Service = inject(BookNoteV2Service);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private selectionService = inject(ReaderSelectionService);
|
||||
private progressService = inject(ReaderProgressService);
|
||||
private leftSidebarService = inject(ReaderLeftSidebarService);
|
||||
@@ -110,15 +112,15 @@ export class ReaderNoteService {
|
||||
this.leftSidebarService.refreshNotes();
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Note Saved',
|
||||
detail: 'Your note has been saved successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.noteSavedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.noteSavedDetail')
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Save Failed',
|
||||
detail: 'Failed to save the note. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.saveFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.saveFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -138,15 +140,15 @@ export class ReaderNoteService {
|
||||
this.leftSidebarService.refreshNotes();
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Note Updated',
|
||||
detail: 'Your note has been updated successfully.'
|
||||
summary: this.t.translate('readerEbook.toast.noteUpdatedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.noteUpdatedDetail')
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: 'Failed to update the note. Please try again.'
|
||||
summary: this.t.translate('readerEbook.toast.updateFailedSummary'),
|
||||
detail: this.t.translate('readerEbook.toast.updateFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.footer'">
|
||||
<div class="reader-navbar" [class.visible]="navbarVisible">
|
||||
<button class="icon-btn" title="Previous Section" [disabled]="!canGoPrevious" (click)="onPreviousSection()">
|
||||
<button class="icon-btn" [title]="t('previousSection')" [disabled]="!canGoPrevious" (click)="onPreviousSection()">
|
||||
<app-reader-icon name="chevron-left"></app-reader-icon>
|
||||
</button>
|
||||
|
||||
<div class="progress-section">
|
||||
<button class="location-btn" (click)="toggleLocationPopover()" title="Location">
|
||||
<button class="location-btn" (click)="toggleLocationPopover()" [title]="t('location')">
|
||||
<span class="location-text">{{ currentPercentage }}%</span>
|
||||
</button>
|
||||
|
||||
@@ -16,7 +17,7 @@
|
||||
[max]="100"
|
||||
[value]="currentPercentage"
|
||||
(input)="onProgressChange($event)"
|
||||
title="Progress"/>
|
||||
[title]="t('progress')"/>
|
||||
<div class="progress-ticks">
|
||||
@for (frac of displaySectionFractions; track frac) {
|
||||
<span class="progress-tick" [style.left.%]="frac * 100"></span>
|
||||
@@ -25,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" title="Next Section" [disabled]="!canGoNext" (click)="onNextSection()">
|
||||
<button class="icon-btn" [title]="t('nextSection')" [disabled]="!canGoNext" (click)="onNextSection()">
|
||||
<app-reader-icon name="chevron-right"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -35,12 +36,12 @@
|
||||
<div class="popover-content">
|
||||
<div class="time-info">
|
||||
<div class="time-section">
|
||||
<span class="label">Time Left in Section</span>
|
||||
<span class="label">{{ t('timeLeftInSection') }}</span>
|
||||
<span class="value">{{ timeSection }}</span>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div class="time-section">
|
||||
<span class="label">Time Left in Book</span>
|
||||
<span class="label">{{ t('timeLeftInBook') }}</span>
|
||||
<span class="value">{{ timeTotal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,18 +50,18 @@
|
||||
|
||||
<div class="book-info">
|
||||
<div class="chapter-info">
|
||||
<span class="label">Chapter</span>
|
||||
<span class="label">{{ t('chapter') }}</span>
|
||||
<span class="chapter-name">{{ currentChapter }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-column">
|
||||
<span class="label">Section</span>
|
||||
<span class="label">{{ t('section') }}</span>
|
||||
<span class="value">{{ sectionCurrent }} / {{ sectionTotal }}</span>
|
||||
</div>
|
||||
@if (progressData?.pageItem) {
|
||||
<div class="info-separator"></div>
|
||||
<div class="info-column">
|
||||
<span class="label">Page</span>
|
||||
<span class="label">{{ t('page') }}</span>
|
||||
<span class="value">{{ currentPage }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -71,26 +72,26 @@
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="goto-row">
|
||||
<label>Go to</label>
|
||||
<label>{{ t('goTo') }}</label>
|
||||
<input type="number" [value]="currentPercentage" #percentInput
|
||||
(keydown.enter)="onGoToPercentage(percentInput.value)" [min]="0" [max]="100"/>
|
||||
<span>%</span>
|
||||
<button class="go-btn" (click)="onGoToPercentage(percentInput.value)" title="Go to percentage">Go</button>
|
||||
<button class="go-btn" (click)="onGoToPercentage(percentInput.value)" [title]="t('goToPercentage')">{{ t('go') }}</button>
|
||||
</div>
|
||||
<div class="section-navigation">
|
||||
<button class="nav-btn" title="First Section" (click)="onFirstSection()">
|
||||
<button class="nav-btn" [title]="t('firstSection')" (click)="onFirstSection()">
|
||||
<app-reader-icon name="chevron-first" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Previous Section" (click)="onPreviousSection()">
|
||||
<button class="nav-btn" [title]="t('previousSection')" (click)="onPreviousSection()">
|
||||
<app-reader-icon name="chevron-left" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Jump To...">
|
||||
<button class="nav-btn" [title]="t('jumpTo')">
|
||||
<app-reader-icon name="dots-vertical" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Next Section" (click)="onNextSection()">
|
||||
<button class="nav-btn" [title]="t('nextSection')" (click)="onNextSection()">
|
||||
<app-reader-icon name="chevron-right" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="Last Section" (click)="onLastSection()">
|
||||
<button class="nav-btn" [title]="t('lastSection')" (click)="onLastSection()">
|
||||
<app-reader-icon name="chevron-last" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,3 +99,4 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Component, EventEmitter, HostListener, inject, Input, Output} from '@angular/core';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderViewManagerService} from '../../core/view-manager.service';
|
||||
import {ReaderIconComponent} from '../../shared/icon.component';
|
||||
|
||||
@@ -30,7 +31,7 @@ interface RelocateEventDetail {
|
||||
@Component({
|
||||
selector: 'app-reader-navbar',
|
||||
standalone: true,
|
||||
imports: [ReaderIconComponent],
|
||||
imports: [TranslocoDirective, ReaderIconComponent],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.header'">
|
||||
<div class="reader-header" [class.visible]="isVisible" [style.background]="currentTheme.bg" [style.color]="currentTheme.fg">
|
||||
<div class="header-left">
|
||||
<button class="icon-btn" (click)="onShowChapters()" title="Chapters">
|
||||
<button class="icon-btn" (click)="onShowChapters()" [title]="t('chapters')">
|
||||
<app-reader-icon name="menu" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onCreateBookmark()" [title]="isCurrentCfiBookmarked ? 'Remove Bookmark' : 'Add Bookmark'" [class.active]="isCurrentCfiBookmarked">
|
||||
<button class="icon-btn" (click)="onCreateBookmark()" [title]="isCurrentCfiBookmarked ? t('removeBookmark') : t('addBookmark')" [class.active]="isCurrentCfiBookmarked">
|
||||
<app-reader-icon name="bookmark"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn" (click)="onOpenSearch()" title="Search">
|
||||
<button class="icon-btn" (click)="onOpenSearch()" [title]="t('search')">
|
||||
<app-reader-icon name="search"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="chapter-title">{{ bookTitle }}</span>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn desktop-only" (click)="onOpenNotes()" title="Notes">
|
||||
<button class="icon-btn desktop-only" (click)="onOpenNotes()" [title]="t('notes')">
|
||||
<app-reader-icon name="note"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn desktop-only" (click)="onToggleFullscreen()" [title]="isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'">
|
||||
<button class="icon-btn desktop-only" (click)="onToggleFullscreen()" [title]="isFullscreen ? t('exitFullscreen') : t('fullscreen')">
|
||||
<app-reader-icon [name]="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn desktop-only" (click)="onShowHelp()" title="Keyboard Shortcuts">
|
||||
<button class="icon-btn desktop-only" (click)="onShowHelp()" [title]="t('keyboardShortcuts')">
|
||||
<app-reader-icon name="help" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
<div class="overflow-menu mobile-only">
|
||||
<button class="icon-btn" (click)="overflowOpen = !overflowOpen; $event.stopPropagation()" title="More">
|
||||
<button class="icon-btn" (click)="overflowOpen = !overflowOpen; $event.stopPropagation()" [title]="t('more')">
|
||||
<app-reader-icon name="dots-vertical" [size]="20"></app-reader-icon>
|
||||
</button>
|
||||
@if (overflowOpen) {
|
||||
@@ -30,24 +31,25 @@
|
||||
<div class="overflow-dropdown" [style.background]="currentTheme.bg" [style.color]="currentTheme.fg">
|
||||
<button class="overflow-item" (click)="onOpenNotes(); overflowOpen = false">
|
||||
<app-reader-icon name="note" [size]="18"></app-reader-icon>
|
||||
<span>Notes</span>
|
||||
<span>{{ t('notes') }}</span>
|
||||
</button>
|
||||
<button class="overflow-item" (click)="onToggleFullscreen(); overflowOpen = false">
|
||||
<app-reader-icon [name]="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" [size]="18"></app-reader-icon>
|
||||
<span>{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</span>
|
||||
<span>{{ isFullscreen ? t('exitFullscreen') : t('fullscreen') }}</span>
|
||||
</button>
|
||||
<button class="overflow-item" (click)="onShowHelp(); overflowOpen = false">
|
||||
<app-reader-icon name="help" [size]="18"></app-reader-icon>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<span>{{ t('keyboardShortcuts') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button class="icon-btn" (click)="onShowControls()" title="Settings">
|
||||
<button class="icon-btn" (click)="onShowControls()" [title]="t('settings')">
|
||||
<app-reader-icon name="settings"></app-reader-icon>
|
||||
</button>
|
||||
<button class="icon-btn close-btn" (click)="onClose(); $event.stopPropagation()" title="Close Reader">
|
||||
<button class="icon-btn close-btn" (click)="onClose(); $event.stopPropagation()" [title]="t('closeReader')">
|
||||
<app-reader-icon name="close"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderHeaderService} from './header.service';
|
||||
import {ReaderIconComponent} from '../../shared/icon.component';
|
||||
import {Router} from '@angular/router';
|
||||
@@ -8,7 +9,7 @@ import {Router} from '@angular/router';
|
||||
@Component({
|
||||
selector: 'app-reader-header',
|
||||
standalone: true,
|
||||
imports: [ReaderIconComponent],
|
||||
imports: [TranslocoDirective, ReaderIconComponent],
|
||||
templateUrl: './header.component.html',
|
||||
styleUrls: ['./header.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.quickSettings'">
|
||||
<div class="quick-settings-overlay" (click)="onOverlayClick()">
|
||||
<div class="quick-settings-panel" (click)="$event.stopPropagation()">
|
||||
<div class="pointer"></div>
|
||||
<div class="panel-body">
|
||||
<!-- Dark Mode -->
|
||||
<div class="control">
|
||||
<label>Dark Mode</label>
|
||||
<label>{{ t('darkMode') }}</label>
|
||||
<div class="control-right">
|
||||
<span class="mode-icon sun" [class.active]="!isDarkMode">☀️</span>
|
||||
<span class="mode-icon sun" [class.active]="!isDarkMode">☀️</span>
|
||||
<button class="switch" [class.active]="isDarkMode" (click)="toggleDarkMode()">
|
||||
<span class="slider"></span>
|
||||
</button>
|
||||
<span class="mode-icon moon" [class.active]="isDarkMode">🌙</span>
|
||||
<span class="mode-icon moon" [class.active]="isDarkMode">🌙</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="control">
|
||||
<label>Font Size</label>
|
||||
<label>{{ t('fontSize') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseFontSize()">−</button>
|
||||
<button (click)="decreaseFontSize()">−</button>
|
||||
<span>{{ state.fontSize }}%</span>
|
||||
<button (click)="increaseFontSize()">+</button>
|
||||
</div>
|
||||
@@ -28,10 +29,10 @@
|
||||
|
||||
<!-- Line Spacing -->
|
||||
<div class="control">
|
||||
<label>Line Spacing</label>
|
||||
<label>{{ t('lineSpacing') }}</label>
|
||||
<div class="control-right">
|
||||
<div class="stepper">
|
||||
<button (click)="decreaseLineHeight()">−</button>
|
||||
<button (click)="decreaseLineHeight()">−</button>
|
||||
<span>{{ state.lineHeight | number:'1.1-1' }}</span>
|
||||
<button (click)="increaseLineHeight()">+</button>
|
||||
</div>
|
||||
@@ -42,8 +43,9 @@
|
||||
<div class="panel-footer">
|
||||
<button class="more-settings-btn" (click)="onOpenFullSettings()">
|
||||
<app-reader-icon name="settings" [size]="16"></app-reader-icon>
|
||||
<span>More Settings</span>
|
||||
<span>{{ t('moreSettings') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {DecimalPipe} from '@angular/common';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderStateService} from '../../state/reader-state.service';
|
||||
import {ReaderIconComponent} from '../../shared/icon.component';
|
||||
import {BookService} from '../../../../book/service/book.service';
|
||||
@@ -8,7 +9,7 @@ import {EbookViewerSetting} from '../../../../book/model/book.model';
|
||||
@Component({
|
||||
selector: 'app-reader-quick-settings',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, ReaderIconComponent],
|
||||
imports: [DecimalPipe, TranslocoDirective, ReaderIconComponent],
|
||||
templateUrl: './quick-settings.component.html',
|
||||
styleUrls: ['./quick-settings.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.panel'">
|
||||
@if (isOpen) {
|
||||
<div class="sidebar-overlay" (click)="onOverlayClick()" [class.closing]="closing">
|
||||
<aside class="sidebar" (click)="$event.stopPropagation()" [class.closing]="closing">
|
||||
@@ -5,9 +6,9 @@
|
||||
<header class="sidebar-header">
|
||||
<h2 class="sidebar-title">
|
||||
@if (activeTab === 'search') {
|
||||
Search
|
||||
{{ t('searchTitle') }}
|
||||
} @else {
|
||||
Notes
|
||||
{{ t('notesTitle') }}
|
||||
}
|
||||
</h2>
|
||||
</header>
|
||||
@@ -19,7 +20,7 @@
|
||||
[class.active]="activeTab === 'search'"
|
||||
(click)="setActiveTab('search')">
|
||||
<app-reader-icon name="search" class="tab-icon"></app-reader-icon>
|
||||
<span>Search</span>
|
||||
<span>{{ t('searchTab') }}</span>
|
||||
@if (searchState.results.length > 0) {
|
||||
<span class="badge">{{ searchState.results.length }}</span>
|
||||
}
|
||||
@@ -29,7 +30,7 @@
|
||||
[class.active]="activeTab === 'notes'"
|
||||
(click)="setActiveTab('notes')">
|
||||
<app-reader-icon name="note" class="tab-icon"></app-reader-icon>
|
||||
<span>Notes</span>
|
||||
<span>{{ t('notesTab') }}</span>
|
||||
@if (notes.length > 0) {
|
||||
<span class="badge">{{ notes.length }}</span>
|
||||
}
|
||||
@@ -46,13 +47,13 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search in book..."
|
||||
[placeholder]="t('searchPlaceholder')"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchInput($event)"
|
||||
autofocus
|
||||
/>
|
||||
@if (searchQuery) {
|
||||
<button class="clear-btn" (click)="clearSearch()" aria-label="Clear search">
|
||||
<button class="clear-btn" (click)="clearSearch()" [attr.aria-label]="t('clearSearch')">
|
||||
<app-reader-icon name="close" [size]="14"></app-reader-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -64,8 +65,8 @@
|
||||
<div class="progress-fill" [style.width.%]="searchState.progress * 100"></div>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">Searching... {{ (searchState.progress * 100) | number:'1.0-0' }}%</span>
|
||||
<button class="cancel-search-btn" (click)="onCancelSearch()" title="Cancel search">
|
||||
<span class="progress-text">{{ t('searching') }} {{ (searchState.progress * 100) | number:'1.0-0' }}%</span>
|
||||
<button class="cancel-search-btn" (click)="onCancelSearch()" [title]="t('cancelSearch')">
|
||||
<app-reader-icon name="close" [size]="14"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -74,7 +75,7 @@
|
||||
|
||||
@if (searchState.results.length > 0) {
|
||||
<div class="search-results-header">
|
||||
<span>{{ searchState.results.length }} result{{ searchState.results.length === 1 ? '' : 's' }} found</span>
|
||||
<span>{{ searchState.results.length === 1 ? t('resultsFound', { count: searchState.results.length }) : t('resultsFoundPlural', { count: searchState.results.length }) }}</span>
|
||||
</div>
|
||||
<ul class="item-list search-results">
|
||||
@for (result of searchState.results; track result.cfi) {
|
||||
@@ -95,14 +96,14 @@
|
||||
} @else if (!searchState.isSearching && searchQuery) {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="search" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No results found</p>
|
||||
<span class="empty-hint">Try different keywords</span>
|
||||
<p>{{ t('noResultsFound') }}</p>
|
||||
<span class="empty-hint">{{ t('tryDifferentKeywords') }}</span>
|
||||
</div>
|
||||
} @else if (!searchQuery) {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="search" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>Search this book</p>
|
||||
<span class="empty-hint">Enter text to find in the book</span>
|
||||
<p>{{ t('searchThisBook') }}</p>
|
||||
<span class="empty-hint">{{ t('enterTextToFind') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -116,12 +117,12 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search notes..."
|
||||
[placeholder]="t('searchNotes')"
|
||||
[(ngModel)]="notesSearchQuery"
|
||||
(ngModelChange)="onNotesSearchInput($event)"
|
||||
/>
|
||||
@if (notesSearchQuery) {
|
||||
<button class="clear-btn" (click)="clearNotesSearch()" aria-label="Clear search">
|
||||
<button class="clear-btn" (click)="clearNotesSearch()" [attr.aria-label]="t('clearSearch')">
|
||||
<app-reader-icon name="close" [size]="14"></app-reader-icon>
|
||||
</button>
|
||||
}
|
||||
@@ -144,10 +145,10 @@
|
||||
<span class="item-meta">{{ note.createdAt | date: 'MMM d, y' }}</span>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button class="edit-btn" (click)="onEditNote($event, note)" aria-label="Edit note">
|
||||
<button class="edit-btn" (click)="onEditNote($event, note)" [attr.aria-label]="t('editNote')">
|
||||
<app-reader-icon name="edit" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
<button class="delete-btn" (click)="onDeleteNote($event, note.id)" aria-label="Delete note">
|
||||
<button class="delete-btn" (click)="onDeleteNote($event, note.id)" [attr.aria-label]="t('deleteNote')">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -157,14 +158,14 @@
|
||||
} @else if (notes.length > 0 && notesSearchQuery) {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="search" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No matching notes</p>
|
||||
<span class="empty-hint">Try different search terms</span>
|
||||
<p>{{ t('noMatchingNotes') }}</p>
|
||||
<span class="empty-hint">{{ t('tryDifferentSearchTerms') }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="note" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No notes yet</p>
|
||||
<span class="empty-hint">Select text and tap the note icon to add a note</span>
|
||||
<p>{{ t('noNotesYet') }}</p>
|
||||
<span class="empty-hint">{{ t('noNotesHint') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -174,3 +175,4 @@
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil, debounceTime, distinctUntilChanged, filter} from 'rxjs/operators';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderLeftSidebarService, LeftSidebarTab} from './panel.service';
|
||||
import {BookNoteV2} from '../../../../../shared/service/book-note-v2.service';
|
||||
import {SearchState, SearchResult} from '../sidebar/sidebar.service';
|
||||
@@ -13,7 +14,7 @@ import {ReaderIconComponent} from '../../shared/icon.component';
|
||||
standalone: true,
|
||||
templateUrl: './panel.component.html',
|
||||
styleUrls: ['./panel.component.scss'],
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent]
|
||||
imports: [CommonModule, FormsModule, TranslocoDirective, ReaderIconComponent]
|
||||
})
|
||||
export class ReaderLeftSidebarComponent implements OnInit, OnDestroy {
|
||||
private leftSidebarService = inject(ReaderLeftSidebarService);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.sidebar'">
|
||||
@if (isOpen) {
|
||||
<div class="sidebar-overlay" (click)="onOverlayClick()" [class.closing]="closing">
|
||||
<aside class="sidebar" (click)="$event.stopPropagation()" [class.closing]="closing">
|
||||
@@ -6,7 +7,7 @@
|
||||
@if (bookInfo.coverUrl) {
|
||||
<img
|
||||
[src]="bookInfo.coverUrl"
|
||||
alt="Book cover"
|
||||
[alt]="t('bookCoverAlt')"
|
||||
class="book-cover"
|
||||
(click)="onCoverClick()"
|
||||
role="button"
|
||||
@@ -29,14 +30,14 @@
|
||||
[class.active]="activeTab === 'chapters'"
|
||||
(click)="setActiveTab('chapters')">
|
||||
<app-reader-icon name="book" class="tab-icon"></app-reader-icon>
|
||||
<span>Contents</span>
|
||||
<span>{{ t('contentsTab') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
[class.active]="activeTab === 'bookmarks'"
|
||||
(click)="setActiveTab('bookmarks')">
|
||||
<app-reader-icon name="bookmark" class="tab-icon"></app-reader-icon>
|
||||
<span>Bookmarks</span>
|
||||
<span>{{ t('bookmarksTab') }}</span>
|
||||
@if (bookmarks.length > 0) {
|
||||
<span class="badge">{{ bookmarks.length }}</span>
|
||||
}
|
||||
@@ -46,7 +47,7 @@
|
||||
[class.active]="activeTab === 'highlights'"
|
||||
(click)="setActiveTab('highlights')">
|
||||
<app-reader-icon name="edit" class="tab-icon"></app-reader-icon>
|
||||
<span>Highlights</span>
|
||||
<span>{{ t('highlightsTab') }}</span>
|
||||
@if (annotations.length > 0) {
|
||||
<span class="badge">{{ annotations.length }}</span>
|
||||
}
|
||||
@@ -101,7 +102,7 @@
|
||||
<span class="item-title">{{ bookmark.title }}</span>
|
||||
<span class="item-meta">{{ bookmark.createdAt | date: 'MMM d, y' }}</span>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="onDeleteBookmark($event, bookmark.id)" aria-label="Delete bookmark">
|
||||
<button class="delete-btn" (click)="onDeleteBookmark($event, bookmark.id)" [attr.aria-label]="t('deleteBookmark')">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -110,8 +111,8 @@
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="bookmark" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No bookmarks yet</p>
|
||||
<span class="empty-hint">Tap the bookmark icon to save your place</span>
|
||||
<p>{{ t('noBookmarksYet') }}</p>
|
||||
<span class="empty-hint">{{ t('noBookmarksHint') }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -129,7 +130,7 @@
|
||||
}
|
||||
<span class="item-meta">{{ annotation.createdAt | date: 'MMM d, y' }}</span>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="onDeleteAnnotation($event, annotation.id)" aria-label="Delete highlight">
|
||||
<button class="delete-btn" (click)="onDeleteAnnotation($event, annotation.id)" [attr.aria-label]="t('deleteHighlight')">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
</li>
|
||||
@@ -138,8 +139,8 @@
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<app-reader-icon name="edit" class="empty-icon" [size]="48" [strokeWidth]="1.5"></app-reader-icon>
|
||||
<p>No highlights yet</p>
|
||||
<span class="empty-hint">Select text to create a highlight</span>
|
||||
<p>{{ t('noHighlightsYet') }}</p>
|
||||
<span class="empty-hint">{{ t('noHighlightsHint') }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -148,3 +149,4 @@
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Subject} from 'rxjs';
|
||||
import {takeUntil} from 'rxjs/operators';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderSidebarService, SidebarBookInfo, SidebarTab} from './sidebar.service';
|
||||
import {TocItem} from 'epubjs';
|
||||
import {BookMark} from '../../../../../shared/service/book-mark.service';
|
||||
@@ -14,7 +15,7 @@ import {ReaderIconComponent} from '../../shared/icon.component';
|
||||
standalone: true,
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.scss'],
|
||||
imports: [CommonModule, FormsModule, ReaderIconComponent]
|
||||
imports: [CommonModule, FormsModule, TranslocoDirective, ReaderIconComponent]
|
||||
})
|
||||
export class ReaderSidebarComponent implements OnInit, OnDestroy {
|
||||
private sidebarService = inject(ReaderSidebarService);
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ThemeInfo {
|
||||
export class PageDecorator {
|
||||
private static readonly DEFAULT_FONT_SIZE = '0.875rem';
|
||||
|
||||
static updateHeadersAndFooters(renderer: any, chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo): void {
|
||||
static updateHeadersAndFooters(renderer: any, chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo, timeRemainingLabel?: string): void {
|
||||
|
||||
if (!renderer) {
|
||||
return;
|
||||
@@ -21,7 +21,7 @@ export class PageDecorator {
|
||||
const isSingleColumn = columnCount === 1;
|
||||
|
||||
this.updateHeaders(renderer, chapterName, isSingleColumn, theme);
|
||||
this.updateFooters(renderer, pageInfo, isSingleColumn, theme);
|
||||
this.updateFooters(renderer, pageInfo, isSingleColumn, theme, timeRemainingLabel);
|
||||
}
|
||||
|
||||
private static updateHeaders(renderer: any, chapterName: string, isSingleColumn: boolean, theme?: ThemeInfo): void {
|
||||
@@ -40,7 +40,7 @@ export class PageDecorator {
|
||||
});
|
||||
}
|
||||
|
||||
private static updateFooters(renderer: any, pageInfo: PageInfo | undefined, isSingleColumn: boolean, theme?: ThemeInfo): void {
|
||||
private static updateFooters(renderer: any, pageInfo: PageInfo | undefined, isSingleColumn: boolean, theme?: ThemeInfo, timeRemainingLabel?: string): void {
|
||||
if (!renderer.feet || !Array.isArray(renderer.feet) || renderer.feet.length === 0 || !pageInfo) {
|
||||
return;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export class PageDecorator {
|
||||
|
||||
renderer.feet.forEach((footElement: HTMLElement, index: number) => {
|
||||
if (footElement) {
|
||||
const footerContent = this.createFooterContent(pageInfo, isSingleColumn, index, renderer.feet.length, footerStyle);
|
||||
const footerContent = this.createFooterContent(pageInfo, isSingleColumn, index, renderer.feet.length, footerStyle, timeRemainingLabel);
|
||||
footElement.replaceChildren(footerContent);
|
||||
}
|
||||
});
|
||||
@@ -88,11 +88,11 @@ export class PageDecorator {
|
||||
return headerContent;
|
||||
}
|
||||
|
||||
private static createFooterContent(pageInfo: PageInfo, isSingleColumn: boolean, index: number, totalColumns: number, style: string): HTMLElement {
|
||||
private static createFooterContent(pageInfo: PageInfo, isSingleColumn: boolean, index: number, totalColumns: number, style: string, timeRemainingLabel?: string): HTMLElement {
|
||||
const footerContent = document.createElement('div');
|
||||
footerContent.style.cssText = style;
|
||||
|
||||
const text = 'Time remaining in section: ' + (pageInfo.sectionTimeText ?? '0s');
|
||||
const text = timeRemainingLabel ?? ('Time remaining in section: ' + (pageInfo.sectionTimeText ?? '0s'));
|
||||
|
||||
if (isSingleColumn) {
|
||||
const timeSpan = document.createElement('span');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<ng-container *transloco="let t; prefix: 'readerEbook.selectionPopup'">
|
||||
@if (visible) {
|
||||
<div class="popup-backdrop" (click)="onDismiss($event)"></div>
|
||||
<div class="text-selection-popup"
|
||||
@@ -5,20 +6,20 @@
|
||||
[style.left.px]="position.x"
|
||||
[style.top.px]="position.y">
|
||||
|
||||
<button class="action-btn" (click)="onSelect()" title="Copy Text">
|
||||
<button class="action-btn" (click)="onSelect()" [title]="t('copyText')">
|
||||
<app-reader-icon name="copy" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button class="action-btn" (click)="onSearch()" title="Search in book">
|
||||
<button class="action-btn" (click)="onSearch()" [title]="t('searchInBook')">
|
||||
<app-reader-icon name="search" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="annotation-container">
|
||||
<button class="action-btn" [class.active]="showAnnotationOptions" (click)="toggleAnnotationOptions()" title="Annotate">
|
||||
<button class="action-btn" [class.active]="showAnnotationOptions" (click)="toggleAnnotationOptions()" [title]="t('annotate')">
|
||||
<app-reader-icon name="edit" [size]="16"></app-reader-icon>
|
||||
<app-reader-icon name="chevron-down" class="chevron" [class.open]="showAnnotationOptions" [size]="10"></app-reader-icon>
|
||||
</button>
|
||||
@@ -57,15 +58,16 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button class="action-btn" (click)="onNote()" title="Add Note">
|
||||
<button class="action-btn" (click)="onNote()" [title]="t('addNote')">
|
||||
<app-reader-icon name="note" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
|
||||
@if (overlappingAnnotationId) {
|
||||
<div class="divider"></div>
|
||||
<button class="action-btn delete-btn" (click)="onDelete()" title="Delete Annotation">
|
||||
<button class="action-btn delete-btn" (click)="onDelete()" [title]="t('deleteAnnotation')">
|
||||
<app-reader-icon name="trash" [size]="16"></app-reader-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {ReaderIconComponent} from './icon.component';
|
||||
|
||||
export type AnnotationStyle = 'highlight' | 'underline' | 'strikethrough' | 'squiggly';
|
||||
@@ -15,7 +16,7 @@ export interface TextSelectionAction {
|
||||
@Component({
|
||||
selector: 'app-text-selection-popup',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReaderIconComponent],
|
||||
imports: [CommonModule, TranslocoDirective, ReaderIconComponent],
|
||||
templateUrl: './selection-popup.component.html',
|
||||
styleUrls: ['./selection-popup.component.scss']
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Subject} from 'rxjs';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {BookPatchService} from '../../../book/service/book-patch.service';
|
||||
import {ReadingSessionService} from '../../../../shared/service/reading-session.service';
|
||||
import {PageInfo, ThemeInfo} from '../core/view-manager.service';
|
||||
@@ -23,6 +24,7 @@ export interface ProgressState {
|
||||
export class ReaderProgressService {
|
||||
private bookPatchService = inject(BookPatchService);
|
||||
private readingSessionService = inject(ReadingSessionService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private viewManager = inject(ReaderViewManagerService);
|
||||
private stateService = inject(ReaderStateService);
|
||||
private annotationService = inject(ReaderAnnotationHttpService);
|
||||
@@ -144,10 +146,15 @@ export class ReaderProgressService {
|
||||
bg: this.stateService.currentState.theme.bg || this.stateService.currentState.theme.light.bg
|
||||
};
|
||||
|
||||
const timeLabel = this.t.translate('readerEbook.headerFooterUtil.timeRemainingInSection', {
|
||||
time: this._currentPageInfo?.sectionTimeText ?? '0s'
|
||||
});
|
||||
|
||||
this.viewManager.updateHeadersAndFooters(
|
||||
this._currentChapterName || '',
|
||||
this._currentPageInfo,
|
||||
theme
|
||||
theme,
|
||||
timeLabel
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import {User, UserService, UserSettings} from '../user-management/user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {filter, takeUntil} from 'rxjs/operators';
|
||||
import {Subject} from 'rxjs';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ReaderPreferencesService implements OnDestroy {
|
||||
private readonly userService = inject(UserService);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
private currentUser: User | null = null;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
@@ -38,8 +40,8 @@ export class ReaderPreferencesService implements OnDestroy {
|
||||
this.userService.updateUserSetting(this.currentUser.id, rootKey, updatedValue);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Preferences Updated',
|
||||
detail: 'Your preferences have been saved successfully.',
|
||||
summary: this.t.translate('settingsReader.toast.preferencesUpdated'),
|
||||
detail: this.t.translate('settingsReader.toast.preferencesUpdatedDetail'),
|
||||
life: 2000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {MetadataRefreshRequest} from '../../metadata/model/request/metadata-refr
|
||||
import {catchError, map} from 'rxjs/operators';
|
||||
import {of} from 'rxjs';
|
||||
import {TaskCreateRequest, TaskService, TaskType} from './task.service';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -11,6 +12,7 @@ import {TaskCreateRequest, TaskService, TaskType} from './task.service';
|
||||
export class TaskHelperService {
|
||||
private taskService = inject(TaskService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
refreshMetadataTask(options: MetadataRefreshRequest) {
|
||||
const request: TaskCreateRequest = {
|
||||
@@ -22,8 +24,8 @@ export class TaskHelperService {
|
||||
map(() => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Metadata Update Scheduled',
|
||||
detail: 'The metadata update for the selected books has been successfully scheduled.'
|
||||
summary: this.t.translate('settingsTasks.toast.metadataScheduled'),
|
||||
detail: this.t.translate('settingsTasks.toast.metadataScheduledDetail')
|
||||
});
|
||||
return {success: true};
|
||||
}),
|
||||
@@ -31,16 +33,16 @@ export class TaskHelperService {
|
||||
if (e.status === 409) {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Task Already Running',
|
||||
summary: this.t.translate('settingsTasks.toast.alreadyRunning'),
|
||||
life: 5000,
|
||||
detail: 'A metadata refresh task is already in progress. Please wait for it to complete before starting another one.'
|
||||
detail: this.t.translate('settingsTasks.toast.metadataAlreadyRunningDetail')
|
||||
});
|
||||
} else {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Metadata Update Failed',
|
||||
summary: this.t.translate('settingsTasks.toast.metadataFailed'),
|
||||
life: 5000,
|
||||
detail: 'An unexpected error occurred while scheduling the metadata update. Please try again later or contact support if the issue persists.'
|
||||
detail: this.t.translate('settingsTasks.toast.metadataFailedDetail')
|
||||
});
|
||||
}
|
||||
return of({success: false});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Component, inject} from '@angular/core';
|
||||
import {NotificationEventService} from '../../websocket/notification-event.service';
|
||||
import {LogNotification} from '../../websocket/model/log-notification.model';
|
||||
import {Tag} from 'primeng/tag';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
import {TagComponent} from '../tag/tag.component';
|
||||
|
||||
@@ -18,7 +19,8 @@ import {TagComponent} from '../tag/tag.component';
|
||||
]
|
||||
})
|
||||
export class LiveNotificationBoxComponent {
|
||||
latestNotification: LogNotification = {message: 'No recent notifications...'};
|
||||
private readonly t = inject(TranslocoService);
|
||||
latestNotification: LogNotification = {message: this.t.translate('shared.liveNotification.defaultMessage')};
|
||||
|
||||
private notificationService = inject(NotificationEventService);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {ButtonModule} from 'primeng/button';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
|
||||
import {MetadataBatchProgressNotification, MetadataBatchStatus, MetadataBatchStatusLabels} from '../../model/metadata-batch-progress.model';
|
||||
import {MetadataProgressService} from '../../service/metadata-progress.service';
|
||||
@@ -31,6 +32,7 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
private metadataTaskService = inject(MetadataTaskService);
|
||||
private taskService = inject(TaskService);
|
||||
private messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
private lastUpdateMap = new Map<string, number>();
|
||||
private timeoutHandles = new Map<string, number>();
|
||||
@@ -79,7 +81,7 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
this.activeTasks[taskId] = {
|
||||
...task,
|
||||
status: MetadataBatchStatus.ERROR,
|
||||
message: 'Task stalled or backend unavailable'
|
||||
message: this.t.translate('shared.metadataProgress.taskStalled')
|
||||
};
|
||||
this.activeTasks = {...this.activeTasks};
|
||||
}
|
||||
@@ -112,23 +114,23 @@ export class MetadataProgressWidgetComponent implements OnInit, OnDestroy {
|
||||
this.activeTasks[taskId] = {
|
||||
...task,
|
||||
status: MetadataBatchStatus.CANCELLED,
|
||||
message: 'Task cancelled by user'
|
||||
message: this.t.translate('shared.metadataProgress.taskCancelled')
|
||||
};
|
||||
this.activeTasks = {...this.activeTasks};
|
||||
}
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Cancellation Scheduled',
|
||||
detail: 'Task cancellation has been successfully scheduled'
|
||||
summary: this.t.translate('shared.metadataProgress.cancellationScheduledSummary'),
|
||||
detail: this.t.translate('shared.metadataProgress.cancellationScheduledDetail')
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to cancel task:', error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Cancel Failed',
|
||||
detail: 'Failed to cancel the task. Please try again.'
|
||||
summary: this.t.translate('shared.metadataProgress.cancelFailedSummary'),
|
||||
detail: this.t.translate('shared.metadataProgress.cancelFailedDetail')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {AppSettingsService} from './app-settings.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {TranslocoService} from '@jsverse/transloco';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
@@ -10,6 +11,7 @@ export class SettingsHelperService {
|
||||
|
||||
private readonly appSettingsService = inject(AppSettingsService);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly t = inject(TranslocoService);
|
||||
|
||||
saveSetting(key: string, value: unknown): Observable<void> {
|
||||
const observable = this.appSettingsService.saveSettings([{key, newValue: value}]);
|
||||
@@ -28,16 +30,16 @@ export class SettingsHelperService {
|
||||
private showSuccessMessage(): void {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Settings Saved',
|
||||
detail: 'The settings were successfully saved!'
|
||||
summary: this.t.translate('shared.settingsHelper.settingsSavedSummary'),
|
||||
detail: this.t.translate('shared.settingsHelper.settingsSavedDetail')
|
||||
});
|
||||
}
|
||||
|
||||
private showErrorMessage(): void {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'There was an error saving the settings.'
|
||||
summary: this.t.translate('common.error'),
|
||||
detail: this.t.translate('shared.settingsHelper.saveErrorDetail')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"oidc": {
|
||||
"loginFailedSummary": "OIDC Login Failed",
|
||||
"redirectingDetail": "Redirecting to local login..."
|
||||
},
|
||||
"login": {
|
||||
"title": "Welcome Back",
|
||||
"subtitle": "Sign in to continue your journey",
|
||||
|
||||
649
booklore-ui/src/i18n/en/book.json
Normal file
649
booklore-ui/src/i18n/en/book.json
Normal file
@@ -0,0 +1,649 @@
|
||||
{
|
||||
"browser": {
|
||||
"confirm": {
|
||||
"deleteMessage": "Are you sure you want to delete {{ count }} book(s)?\n\nThis will permanently remove the book files from your filesystem.\n\nThis action cannot be undone.",
|
||||
"deleteHeader": "Confirm Deletion",
|
||||
"regenCoverMessage": "Are you sure you want to regenerate covers for {{ count }} book(s)?",
|
||||
"regenCoverHeader": "Confirm Cover Regeneration",
|
||||
"customCoverMessage": "Are you sure you want to generate custom covers for {{ count }} book(s)?",
|
||||
"customCoverHeader": "Confirm Custom Cover Generation"
|
||||
},
|
||||
"toast": {
|
||||
"sortSavedSummary": "Sort Saved",
|
||||
"sortSavedGlobalDetail": "Default sort configuration saved.",
|
||||
"sortSavedEntityDetail": "Sort configuration saved for this {{ entityType }}.",
|
||||
"regenCoverStartedSummary": "Cover Regeneration Started",
|
||||
"regenCoverStartedDetail": "Regenerating covers for {{ count }} book(s). Refresh the page when complete.",
|
||||
"customCoverStartedSummary": "Custom Cover Generation Started",
|
||||
"customCoverStartedDetail": "Generating custom covers for {{ count }} book(s).",
|
||||
"failedSummary": "Failed",
|
||||
"regenCoverFailedDetail": "Could not start cover regeneration.",
|
||||
"customCoverFailedDetail": "Could not start custom cover generation.",
|
||||
"unshelveSuccessDetail": "Books shelves updated",
|
||||
"unshelveFailedDetail": "Failed to update books shelves",
|
||||
"noEligibleBooksSummary": "No Eligible Books",
|
||||
"noEligibleBooksDetail": "Selected books must be single-file books (no alternative formats).",
|
||||
"multipleLibrariesSummary": "Multiple Libraries",
|
||||
"multipleLibrariesDetail": "All selected books must be from the same library."
|
||||
},
|
||||
"loading": {
|
||||
"deleting": "Deleting {{ count }} book(s)...",
|
||||
"unshelving": "Unshelving {{ count }} book(s)..."
|
||||
},
|
||||
"labels": {
|
||||
"allBooks": "All Books",
|
||||
"unshelvedBooks": "Unshelved Books",
|
||||
"unshelvedBooksFiltered": "Unshelved Books (Filtered)",
|
||||
"filteredSuffix": "(Filtered)",
|
||||
"seriesCollapsedInfo": "Showing {{ count }} {{ itemWord }} (series collapsed)",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"selected": "selected",
|
||||
"setAsDefault": "Set as default?",
|
||||
"save": "Save",
|
||||
"collapseSeries": "Collapse series",
|
||||
"gridColumns": "Grid columns",
|
||||
"gridItemSize": "Grid item size",
|
||||
"bookTypeOverlay": "Book type overlay",
|
||||
"noBooks": "This collection has no books!",
|
||||
"failedLibrary": "Failed to load library's books!",
|
||||
"failedShelf": "Failed to load shelf's books!",
|
||||
"activeFilters": "{{ count }} Active Filters"
|
||||
},
|
||||
"tooltip": {
|
||||
"clearFilters": "Clear applied filters",
|
||||
"visibleColumns": "Visible columns",
|
||||
"displaySettings": "Display settings",
|
||||
"selectSorting": "Select sorting",
|
||||
"toggleView": "Toggle between Grid and Table view",
|
||||
"search": "Search",
|
||||
"toggleSidebar": "Toggle sidebar filters",
|
||||
"metadataActions": "Metadata actions",
|
||||
"assignToShelf": "Assign to shelf",
|
||||
"removeFromShelf": "Remove from this shelf",
|
||||
"lockUnlockMetadata": "Lock/Unlock metadata",
|
||||
"organizeFiles": "Organize Files",
|
||||
"attachToBook": "Attach to Another Book",
|
||||
"moreActions": "More actions",
|
||||
"selectAll": "Select all books",
|
||||
"deselectAll": "Deselect all books",
|
||||
"deleteSelected": "Delete selected books"
|
||||
},
|
||||
"placeholder": {
|
||||
"selectColumns": "Select Columns",
|
||||
"search": "Title, Author, Series, Genre, or ISBN..."
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"menu": {
|
||||
"assignShelf": "Assign Shelf",
|
||||
"viewDetails": "View Details",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"emailBook": "Email Book",
|
||||
"quickSend": "Quick Send",
|
||||
"customSend": "Custom Send",
|
||||
"metadata": "Metadata",
|
||||
"searchMetadata": "Search Metadata",
|
||||
"autoFetch": "Auto Fetch",
|
||||
"customFetch": "Custom Fetch",
|
||||
"regenerateCover": "Regenerate Cover (File)",
|
||||
"generateCustomCover": "Generate Custom Cover",
|
||||
"moreActions": "More Actions",
|
||||
"organizeFile": "Organize File",
|
||||
"readStatus": "Read Status",
|
||||
"resetBookloreProgress": "Reset Booklore Progress",
|
||||
"resetKOReaderProgress": "Reset KOReader Progress",
|
||||
"book": "Book",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteBookMessage": "Are you sure you want to delete \"{{ title }}\"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.",
|
||||
"deleteBookHeader": "Confirm Deletion",
|
||||
"deleteFileMessage": "Are you sure you want to delete the additional file \"{{ fileName }}\"?",
|
||||
"deleteFileHeader": "Confirm File Deletion"
|
||||
},
|
||||
"toast": {
|
||||
"quickSendSuccessDetail": "The book sending has been scheduled.",
|
||||
"quickSendErrorDetail": "An error occurred while sending the book.",
|
||||
"deleteFileSuccessDetail": "Additional file \"{{ fileName }}\" deleted successfully",
|
||||
"deleteFileErrorDetail": "Failed to delete additional file: {{ error }}",
|
||||
"readStatusUpdatedSummary": "Read Status Updated",
|
||||
"readStatusUpdatedDetail": "Marked as \"{{ label }}\"",
|
||||
"readStatusFailedSummary": "Update Failed",
|
||||
"readStatusFailedDetail": "Could not update read status.",
|
||||
"progressResetSummary": "Progress Reset",
|
||||
"progressResetBookloreDetail": "Booklore reading progress has been reset.",
|
||||
"progressResetKOReaderDetail": "KOReader reading progress has been reset.",
|
||||
"progressResetFailedSummary": "Failed",
|
||||
"progressResetBookloreFailedDetail": "Could not reset Booklore progress.",
|
||||
"progressResetKOReaderFailedDetail": "Could not reset KOReader progress.",
|
||||
"coverRegenSuccessSummary": "Success",
|
||||
"coverRegenSuccessDetail": "Cover regeneration started",
|
||||
"coverRegenFailedDetail": "Failed to regenerate cover",
|
||||
"customCoverSuccessSummary": "Success",
|
||||
"customCoverSuccessDetail": "Cover generated successfully",
|
||||
"customCoverFailedDetail": "Failed to generate cover"
|
||||
},
|
||||
"alt": {
|
||||
"cover": "Cover of {{ title }}",
|
||||
"titleTooltip": "Title: {{ title }}",
|
||||
"seriesCollapsed": "Series collapsed: {{ count }} books"
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"confirm": {
|
||||
"deleteMessage": "Are you sure you want to delete the note \"{{ title }}\"?",
|
||||
"deleteHeader": "Confirm Deletion"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedDetail": "Failed to load notes for this book.",
|
||||
"validationSummary": "Validation Error",
|
||||
"validationDetail": "Both title and content are required.",
|
||||
"createSuccessDetail": "Note created successfully.",
|
||||
"createFailedDetail": "Failed to create note.",
|
||||
"updateSuccessDetail": "Note updated successfully.",
|
||||
"updateFailedDetail": "Failed to update note.",
|
||||
"deleteSuccessDetail": "Note deleted successfully.",
|
||||
"deleteFailedDetail": "Failed to delete note."
|
||||
}
|
||||
},
|
||||
"bookService": {
|
||||
"toast": {
|
||||
"someFilesNotDeletedSummary": "Some files could not be deleted",
|
||||
"someFilesNotDeletedDetail": "Books: {{ fileNames }}",
|
||||
"booksDeletedSummary": "Books Deleted",
|
||||
"booksDeletedDetail": "{{ count }} book(s) deleted successfully.",
|
||||
"deleteFailedSummary": "Delete Failed",
|
||||
"deleteFailedDetail": "An error occurred while deleting books.",
|
||||
"physicalBookCreatedSummary": "Physical Book Created",
|
||||
"physicalBookCreatedDetail": "\"{{ title }}\" has been added to your library.",
|
||||
"creationFailedSummary": "Creation Failed",
|
||||
"creationFailedDetail": "An error occurred while creating the physical book.",
|
||||
"fileDeletedSummary": "File Deleted",
|
||||
"additionalFileDeletedDetail": "Additional file deleted successfully.",
|
||||
"bookFileDeletedDetail": "Book file deleted successfully.",
|
||||
"fileDeleteFailedSummary": "Delete Failed",
|
||||
"fileDeleteFailedDetail": "An error occurred while deleting the file.",
|
||||
"fileUploadedSummary": "File Uploaded",
|
||||
"fileUploadedDetail": "Additional file uploaded successfully.",
|
||||
"uploadFailedSummary": "Upload Failed",
|
||||
"uploadFailedDetail": "An error occurred while uploading the file.",
|
||||
"fieldLockFailedSummary": "Field Lock Update Failed",
|
||||
"fieldLockFailedDetail": "Failed to update metadata field locks. Please try again.",
|
||||
"filesAttachedSummary": "Files Attached",
|
||||
"filesAttachedDetail": "{{ count }} book file(s) have been attached successfully.",
|
||||
"attachmentFailedSummary": "Attachment Failed",
|
||||
"attachmentFailedDetail": "An error occurred while attaching the files."
|
||||
}
|
||||
},
|
||||
"menuService": {
|
||||
"menu": {
|
||||
"autoFetchMetadata": "Auto Fetch Metadata",
|
||||
"customFetchMetadata": "Custom Fetch Metadata",
|
||||
"bulkMetadataEditor": "Bulk Metadata Editor",
|
||||
"multiBookMetadataEditor": "Multi-Book Metadata Editor",
|
||||
"regenerateCovers": "Regenerate Covers",
|
||||
"generateCustomCovers": "Generate Custom Covers",
|
||||
"updateReadStatus": "Update Read Status",
|
||||
"setAgeRating": "Set Age Rating",
|
||||
"clearAgeRating": "Clear Age Rating",
|
||||
"setContentRating": "Set Content Rating",
|
||||
"clearContentRating": "Clear Content Rating",
|
||||
"removeFromAllShelves": "Remove from all shelves",
|
||||
"resetBookloreProgress": "Reset Booklore Progress",
|
||||
"resetKOReaderProgress": "Reset KOReader Progress"
|
||||
},
|
||||
"confirm": {
|
||||
"readStatusMessage": "Are you sure you want to mark {{ count }} book(s) as \"{{ label }}\"?",
|
||||
"readStatusHeader": "Confirm Read Status Update",
|
||||
"ageRatingMessage": "Are you sure you want to set the age rating to \"{{ label }}\" for {{ count }} book(s)?",
|
||||
"ageRatingHeader": "Confirm Age Rating Update",
|
||||
"clearAgeRatingMessage": "Are you sure you want to clear the age rating for {{ count }} book(s)?",
|
||||
"clearAgeRatingHeader": "Confirm Clear Age Rating",
|
||||
"contentRatingMessage": "Are you sure you want to set the content rating to \"{{ label }}\" for {{ count }} book(s)?",
|
||||
"contentRatingHeader": "Confirm Content Rating Update",
|
||||
"clearContentRatingMessage": "Are you sure you want to clear the content rating for {{ count }} book(s)?",
|
||||
"clearContentRatingHeader": "Confirm Clear Content Rating",
|
||||
"unshelveMessage": "Are you sure you want to remove {{ count }} book(s) from ALL their shelves?",
|
||||
"unshelveHeader": "Confirm Unshelve",
|
||||
"resetBookloreMessage": "Are you sure you want to reset Booklore reading progress for {{ count }} book(s)?",
|
||||
"resetKOReaderMessage": "Are you sure you want to reset KOReader reading progress for {{ count }} book(s)?",
|
||||
"resetHeader": "Confirm Reset"
|
||||
},
|
||||
"toast": {
|
||||
"readStatusUpdatedSummary": "Read Status Updated",
|
||||
"readStatusUpdatedDetail": "Marked as \"{{ label }}\"",
|
||||
"updateFailedSummary": "Update Failed",
|
||||
"readStatusFailedDetail": "Could not update read status.",
|
||||
"ageRatingUpdatedSummary": "Age Rating Updated",
|
||||
"ageRatingUpdatedDetail": "Set to \"{{ label }}\"",
|
||||
"ageRatingFailedDetail": "Could not update age rating.",
|
||||
"ageRatingClearedSummary": "Age Rating Cleared",
|
||||
"ageRatingClearedDetail": "Age rating has been cleared.",
|
||||
"clearAgeRatingFailedDetail": "Could not clear age rating.",
|
||||
"contentRatingUpdatedSummary": "Content Rating Updated",
|
||||
"contentRatingUpdatedDetail": "Set to \"{{ label }}\"",
|
||||
"contentRatingFailedDetail": "Could not update content rating.",
|
||||
"contentRatingClearedSummary": "Content Rating Cleared",
|
||||
"contentRatingClearedDetail": "Content rating has been cleared.",
|
||||
"clearContentRatingFailedDetail": "Could not clear content rating.",
|
||||
"noBooksOnShelvesDetail": "Selected books are not on any shelves.",
|
||||
"unshelveSuccessDetail": "Books removed from all shelves",
|
||||
"unshelveFailedDetail": "Failed to update books shelves",
|
||||
"progressResetSummary": "Progress Reset",
|
||||
"bookloreProgressResetDetail": "Booklore reading progress has been reset.",
|
||||
"koreaderProgressResetDetail": "KOReader reading progress has been reset.",
|
||||
"failedSummary": "Failed",
|
||||
"progressResetFailedDetail": "Could not reset progress."
|
||||
},
|
||||
"loading": {
|
||||
"updatingReadStatus": "Updating read status for {{ count }} book(s)...",
|
||||
"settingAgeRating": "Setting age rating for {{ count }} book(s)...",
|
||||
"clearingAgeRating": "Clearing age rating for {{ count }} book(s)...",
|
||||
"settingContentRating": "Setting content rating for {{ count }} book(s)...",
|
||||
"clearingContentRating": "Clearing content rating for {{ count }} book(s)...",
|
||||
"removingFromShelves": "Removing {{ count }} book(s) from shelves...",
|
||||
"resettingBookloreProgress": "Resetting Booklore progress for {{ count }} book(s)...",
|
||||
"resettingKOReaderProgress": "Resetting KOReader progress for {{ count }} book(s)..."
|
||||
}
|
||||
},
|
||||
"shelfMenuService": {
|
||||
"library": {
|
||||
"optionsLabel": "Options",
|
||||
"addPhysicalBook": "Add Physical Book",
|
||||
"editLibrary": "Edit Library",
|
||||
"rescanLibrary": "Re-scan Library",
|
||||
"customFetchMetadata": "Custom Fetch Metadata",
|
||||
"autoFetchMetadata": "Auto Fetch Metadata",
|
||||
"deleteLibrary": "Delete Library"
|
||||
},
|
||||
"shelf": {
|
||||
"publicShelfPrefix": "Public Shelf - ",
|
||||
"readOnly": "Read only",
|
||||
"optionsLabel": "Options",
|
||||
"editShelf": "Edit Shelf",
|
||||
"deleteShelf": "Delete Shelf"
|
||||
},
|
||||
"magicShelf": {
|
||||
"optionsLabel": "Options",
|
||||
"editMagicShelf": "Edit Magic Shelf",
|
||||
"deleteMagicShelf": "Delete Magic Shelf"
|
||||
},
|
||||
"confirm": {
|
||||
"rescanLibraryMessage": "Are you sure you want to refresh library: {{ name }}?",
|
||||
"deleteLibraryMessage": "Are you sure you want to delete library: {{ name }}?",
|
||||
"deleteShelfMessage": "Are you sure you want to delete shelf: {{ name }}?",
|
||||
"deleteMagicShelfMessage": "Are you sure you want to delete magic shelf: {{ name }}?",
|
||||
"header": "Confirmation",
|
||||
"rescanLabel": "Rescan"
|
||||
},
|
||||
"toast": {
|
||||
"libraryRefreshSuccessDetail": "Library refresh scheduled",
|
||||
"libraryRefreshFailedDetail": "Failed to refresh library",
|
||||
"libraryDeletedDetail": "Library was deleted",
|
||||
"libraryDeleteFailedDetail": "Failed to delete library",
|
||||
"shelfDeletedDetail": "Shelf was deleted",
|
||||
"shelfDeleteFailedDetail": "Failed to delete shelf",
|
||||
"magicShelfDeletedDetail": "Magic shelf was deleted",
|
||||
"magicShelfDeleteFailedDetail": "Failed to delete shelf",
|
||||
"failedSummary": "Failed"
|
||||
},
|
||||
"loading": {
|
||||
"deletingLibrary": "Deleting library '{{ name }}'..."
|
||||
}
|
||||
},
|
||||
"columnPref": {
|
||||
"columns": {
|
||||
"readStatus": "Read",
|
||||
"title": "Title",
|
||||
"authors": "Authors",
|
||||
"publisher": "Publisher",
|
||||
"seriesName": "Series",
|
||||
"seriesNumber": "Series #",
|
||||
"categories": "Genres",
|
||||
"publishedDate": "Published",
|
||||
"lastReadTime": "Last Read",
|
||||
"addedOn": "Added",
|
||||
"fileName": "File Name",
|
||||
"fileSizeKb": "File Size",
|
||||
"language": "Language",
|
||||
"isbn": "ISBN",
|
||||
"pageCount": "Pages",
|
||||
"amazonRating": "Amazon",
|
||||
"amazonReviewCount": "AZ #",
|
||||
"goodreadsRating": "Goodreads",
|
||||
"goodreadsReviewCount": "GR #",
|
||||
"hardcoverRating": "Hardcover",
|
||||
"hardcoverReviewCount": "HC #",
|
||||
"ranobedbRating": "Ranobedb"
|
||||
},
|
||||
"toast": {
|
||||
"savedSummary": "Preferences Saved",
|
||||
"savedDetail": "Your column layout has been saved."
|
||||
}
|
||||
},
|
||||
"coverPref": {
|
||||
"toast": {
|
||||
"savedSummary": "Cover Size Saved",
|
||||
"savedDetail": "Cover size set to {{ scale }}x.",
|
||||
"saveFailedSummary": "Save Failed",
|
||||
"saveFailedDetail": "Could not save cover size preference locally."
|
||||
}
|
||||
},
|
||||
"filterPref": {
|
||||
"toast": {
|
||||
"saveFailedSummary": "Save Failed",
|
||||
"saveFailedDetail": "Could not save sidebar filter preference locally."
|
||||
}
|
||||
},
|
||||
"reviews": {
|
||||
"confirm": {
|
||||
"deleteAllMessage": "Are you sure you want to delete all {{ count }} reviews for this book? This action cannot be undone.",
|
||||
"deleteAllHeader": "Confirm Delete All",
|
||||
"deleteMessage": "Are you sure you want to delete this review by {{ reviewer }}?",
|
||||
"deleteHeader": "Confirm Deletion"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailedSummary": "Failed to Load Reviews",
|
||||
"loadFailedDetail": "Could not load reviews for this book.",
|
||||
"reviewsUpdatedSummary": "Reviews Updated",
|
||||
"reviewsUpdatedDetail": "Latest reviews have been fetched successfully.",
|
||||
"fetchFailedSummary": "Fetch Failed",
|
||||
"fetchFailedDetail": "Could not fetch new reviews for this book.",
|
||||
"allDeletedSummary": "All Reviews Deleted",
|
||||
"allDeletedDetail": "All reviews have been successfully deleted.",
|
||||
"deleteAllFailedSummary": "Delete Failed",
|
||||
"deleteAllFailedDetail": "Could not delete all reviews.",
|
||||
"deleteSuccessSummary": "Review Deleted",
|
||||
"deleteSuccessDetail": "The review has been successfully deleted.",
|
||||
"deleteFailedSummary": "Delete Failed",
|
||||
"deleteFailedDetail": "Could not delete the review.",
|
||||
"lockedSummary": "Reviews Locked",
|
||||
"lockedDetail": "Reviews are now protected from modifications and refreshes.",
|
||||
"unlockedSummary": "Reviews Unlocked",
|
||||
"unlockedDetail": "Reviews can now be modified and refreshed.",
|
||||
"lockFailedSummary": "Lock Toggle Failed",
|
||||
"lockFailedDetail": "Could not change the lock status for reviews."
|
||||
},
|
||||
"labels": {
|
||||
"loadingReviews": "Getting latest reviews...",
|
||||
"showSpoiler": "Show Spoiler",
|
||||
"anonymous": "Anonymous",
|
||||
"spoiler": "Spoiler"
|
||||
},
|
||||
"empty": {
|
||||
"noReviews": "No reviews available for this book",
|
||||
"downloadsDisabled": "Book review downloads are currently disabled. Enable this in Metadata Settings to fetch reviews.",
|
||||
"fetchPrompt": "Click \"Fetch Reviews\" to download reviews from configured providers",
|
||||
"noContent": "No review content available"
|
||||
},
|
||||
"tooltip": {
|
||||
"fetchReviews": "Fetch Reviews",
|
||||
"reviewsLocked": "Reviews are locked",
|
||||
"deleteReview": "Delete Review",
|
||||
"pleaseWait": "Please wait while reviews are being fetched",
|
||||
"enableDownloads": "Enable review downloads in settings to use this feature",
|
||||
"unlockReviews": "Unlock Reviews",
|
||||
"lockReviews": "Lock Reviews",
|
||||
"fetchNewReviews": "Fetch New Reviews",
|
||||
"deleteAllReviews": "Delete All Reviews",
|
||||
"hideAllSpoilers": "Hide All Spoilers",
|
||||
"revealAllSpoilers": "Reveal All Spoilers",
|
||||
"sortNewest": "Sort by Newest First",
|
||||
"sortOldest": "Sort by Oldest First"
|
||||
}
|
||||
},
|
||||
"addPhysicalBook": {
|
||||
"title": "Add Physical Book",
|
||||
"description": "Catalog a physical book without a digital file",
|
||||
"closeTooltip": "Close",
|
||||
"libraryLabel": "Library",
|
||||
"libraryPlaceholder": "Select a library",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "e.g., The Great Gatsby",
|
||||
"isbnLabel": "ISBN",
|
||||
"isbnPlaceholder": "e.g., 9780134685991",
|
||||
"authorsLabel": "Authors",
|
||||
"authorsPlaceholder": "Type author name and press Enter",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Brief description of the book...",
|
||||
"publisherLabel": "Publisher",
|
||||
"publisherPlaceholder": "Publisher name",
|
||||
"publishedDateLabel": "Published Date",
|
||||
"publishedDatePlaceholder": "e.g., 2020 or 2020-05-15",
|
||||
"languageLabel": "Language",
|
||||
"languagePlaceholder": "e.g., English",
|
||||
"pageCountLabel": "Page Count",
|
||||
"pageCountPlaceholder": "Number of pages",
|
||||
"categoriesLabel": "Categories/Genres",
|
||||
"categoriesPlaceholder": "Type category and press Enter",
|
||||
"validationLibraryRequired": "Library is required",
|
||||
"validationTitleOrIsbn": "Title or ISBN is required",
|
||||
"validationReady": "Ready to create",
|
||||
"cancelButton": "Cancel",
|
||||
"addButton": "Add Physical Book"
|
||||
},
|
||||
"fileUploader": {
|
||||
"title": "Upload Additional File",
|
||||
"unknownTitle": "Unknown Title",
|
||||
"selectFileType": "Select file type",
|
||||
"typeAlternativeFormat": "Alternative Format",
|
||||
"typeSupplementary": "Supplementary File",
|
||||
"descriptionLabel": "Description (Optional)",
|
||||
"descriptionPlaceholder": "Add a description for this file...",
|
||||
"statusUploading": "Uploading",
|
||||
"statusUploaded": "Uploaded",
|
||||
"statusUploadFailed": "Upload failed",
|
||||
"statusTooLarge": "Too Large",
|
||||
"statusReady": "Ready",
|
||||
"statusFailed": "Failed",
|
||||
"dragDropText": "Drag and drop a file here to upload.",
|
||||
"uploadForBook": "Upload an additional file for <strong>{{ title }}</strong>.",
|
||||
"thisBook": "this book",
|
||||
"toast": {
|
||||
"fileTooLargeSummary": "File Too Large",
|
||||
"fileTooLargeDetail": "{{ fileName }} exceeds the maximum file size of {{ maxSize }}",
|
||||
"fileTooLargeError": "File exceeds maximum size of {{ maxSize }}",
|
||||
"uploadFailedUnknown": "Upload failed due to unknown error."
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"title": "Filters",
|
||||
"showingFirst100": "Showing first 100 items",
|
||||
"footerNote": "Note: Top 100 items are displayed per filter category"
|
||||
},
|
||||
"shelfAssigner": {
|
||||
"title": "Assign Books to Shelves",
|
||||
"descriptionMulti": "Select shelves for {{ count }} book(s)",
|
||||
"descriptionSingle": "Organize \"{{ title }}\" into your shelves",
|
||||
"shelvesAvailable": "{{ count }} shelf(ves) available",
|
||||
"emptyTitle": "No Shelves Available",
|
||||
"emptyDescription": "Create your first shelf to start organizing your books.<br/>Click the button below to get started.",
|
||||
"createShelf": "Create Shelf",
|
||||
"cancelButton": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"loading": {
|
||||
"updatingShelves": "Updating shelves for {{ count }} book(s)..."
|
||||
},
|
||||
"toast": {
|
||||
"updateSuccessDetail": "Book shelves updated",
|
||||
"updateFailedDetail": "Failed to update book shelves"
|
||||
}
|
||||
},
|
||||
"shelfCreator": {
|
||||
"title": "Create New Shelf",
|
||||
"description": "Add a custom shelf to organize your books",
|
||||
"closeTooltip": "Close",
|
||||
"shelfNameLabel": "Shelf Name",
|
||||
"shelfNamePlaceholder": "e.g., Favorites, To Read, Currently Reading",
|
||||
"shelfIconLabel": "Shelf Icon (Optional)",
|
||||
"chooseIcon": "Choose an Icon",
|
||||
"chooseIconSubtitle": "Select from available icons",
|
||||
"selectedIcon": "Selected Icon",
|
||||
"removeIconTooltip": "Remove icon",
|
||||
"visibilityLabel": "Visibility",
|
||||
"makePublicLabel": "Make this shelf public (read-only for others)",
|
||||
"validationRequired": "Shelf name is required",
|
||||
"validationReady": "Ready to create",
|
||||
"cancelButton": "Cancel",
|
||||
"createButton": "Create Shelf",
|
||||
"toast": {
|
||||
"createSuccessDetail": "Shelf created: {{ name }}",
|
||||
"createFailedDetail": "Failed to create shelf"
|
||||
}
|
||||
},
|
||||
"shelfEditDialog": {
|
||||
"title": "Edit Shelf",
|
||||
"description": "Customize your shelf name and icon",
|
||||
"shelfNameLabel": "Shelf Name:",
|
||||
"shelfNamePlaceholder": "Enter shelf name...",
|
||||
"shelfIconLabel": "Shelf Icon:",
|
||||
"selectIcon": "Select Icon",
|
||||
"visibilityLabel": "Visibility:",
|
||||
"publicShelfLabel": "Public Shelf",
|
||||
"cancelButton": "Cancel",
|
||||
"saveChanges": "Save Changes",
|
||||
"toast": {
|
||||
"updateSuccessSummary": "Shelf Updated",
|
||||
"updateSuccessDetail": "The shelf was updated successfully.",
|
||||
"updateFailedSummary": "Update Failed",
|
||||
"updateFailedDetail": "An error occurred while updating the shelf. Please try again."
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"bookCoverAlt": "Book Cover",
|
||||
"statusPrefix": "Status: ",
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked",
|
||||
"toast": {
|
||||
"metadataLockedSummary": "Metadata Locked",
|
||||
"metadataLockedDetail": "Book metadata has been locked successfully.",
|
||||
"metadataUnlockedSummary": "Metadata Unlocked",
|
||||
"metadataUnlockedDetail": "Book metadata has been unlocked successfully.",
|
||||
"lockFailedSummary": "Failed to Lock",
|
||||
"lockFailedDetail": "An error occurred while locking the metadata.",
|
||||
"unlockFailedSummary": "Failed to Unlock",
|
||||
"unlockFailedDetail": "An error occurred while unlocking the metadata."
|
||||
}
|
||||
},
|
||||
"lockUnlockDialog": {
|
||||
"title": "Lock or Unlock Metadata",
|
||||
"selectedCount": "{{ count }} book(s) selected",
|
||||
"reset": "Reset",
|
||||
"lockAll": "Lock All",
|
||||
"unlockAll": "Unlock All",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked",
|
||||
"unselected": "Unselected",
|
||||
"toast": {
|
||||
"updatingFieldLocks": "Updating field locks...",
|
||||
"updatedSummary": "Field Locks Updated",
|
||||
"updatedDetail": "Selected metadata fields have been updated successfully.",
|
||||
"failedSummary": "Failed to Update Field Locks",
|
||||
"failedDetail": "An error occurred while updating field lock statuses."
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"sortOrder": "Sort Order",
|
||||
"removeTooltip": "Remove",
|
||||
"addSortFieldPlaceholder": "Add sort field...",
|
||||
"saveAsDefault": "Save as Default",
|
||||
"ascendingTooltip": "Ascending - click to change",
|
||||
"descendingTooltip": "Descending - click to change"
|
||||
},
|
||||
"fileAttacher": {
|
||||
"title": "Attach File to Another Book",
|
||||
"titleBulk": "Attach Files to Another Book",
|
||||
"description": "Move this book's file to another book as alternative format",
|
||||
"descriptionBulk": "Move these books' files to another book as alternative formats",
|
||||
"sourceBookLabel": "Source Book ({{ count }})",
|
||||
"sourceBooksLabel": "Source Books ({{ count }})",
|
||||
"unknownTitle": "Unknown Title",
|
||||
"selectTargetBook": "Select Target Book",
|
||||
"searchPlaceholder": "Search for a book in the same library...",
|
||||
"deleteSource": "Delete source book after attachment",
|
||||
"deleteSourceBulk": "Delete source books after attachment",
|
||||
"warningMove": "This will move the file from the source book to the target book as alternative format.",
|
||||
"warningMoveBulk": "This will move the files from the selected books to the target book as alternative formats.",
|
||||
"warningDelete": "The source book record will be deleted (file will be preserved in target book).",
|
||||
"warningDeleteBulk": "The source book records will be deleted (files will be preserved in target book).",
|
||||
"warningKeep": "The source book record will remain but will have no readable files.",
|
||||
"warningKeepBulk": "The source book records will remain but will have no readable files.",
|
||||
"attachFile": "Attach File",
|
||||
"attachFilesBulk": "Attach Files",
|
||||
"unknownFile": "Unknown file",
|
||||
"unknownFormat": "Unknown",
|
||||
"unknownFilename": "Unknown filename"
|
||||
},
|
||||
"searcher": {
|
||||
"placeholder": "Title, Author, Series, Genre, or ISBN...",
|
||||
"clearSearch": "Clear Search",
|
||||
"bookCoverAlt": "Book Cover",
|
||||
"byPrefix": "by",
|
||||
"noResults": "No results found",
|
||||
"unknownAuthor": "Unknown Author"
|
||||
},
|
||||
"sender": {
|
||||
"title": "Send Book",
|
||||
"description": "Email this book to a recipient",
|
||||
"emailProvider": "Email Provider",
|
||||
"selectProvider": "Select Email Provider",
|
||||
"recipient": "Recipient",
|
||||
"selectRecipient": "Select Book Recipient",
|
||||
"fileFormat": "File Format",
|
||||
"unknownFormat": "Unknown",
|
||||
"primaryBadge": "Primary",
|
||||
"largeFileWarning": "This file exceeds 25MB. Some email providers may reject large attachments.",
|
||||
"sendBook": "Send Book",
|
||||
"toast": {
|
||||
"emailScheduledSummary": "Email Scheduled",
|
||||
"emailScheduledDetail": "The book has been successfully scheduled for sending.",
|
||||
"sendingFailedSummary": "Sending Failed",
|
||||
"sendingFailedDetail": "There was an issue while scheduling the book for sending. Please try again later.",
|
||||
"providerMissingSummary": "Email Provider Missing",
|
||||
"providerMissingDetail": "Please select an email provider to proceed.",
|
||||
"recipientMissingSummary": "Recipient Missing",
|
||||
"recipientMissingDetail": "Please select a recipient to send the book.",
|
||||
"bookNotSelectedSummary": "Book Not Selected",
|
||||
"bookNotSelectedDetail": "Please select a book to send."
|
||||
}
|
||||
},
|
||||
"seriesPage": {
|
||||
"seriesDetailsTab": "Series Details",
|
||||
"publisher": "Publisher:",
|
||||
"years": "Years:",
|
||||
"numberOfBooks": "Number of books:",
|
||||
"language": "Language:",
|
||||
"readStatus": "Read Status:",
|
||||
"noDescription": "No description available.",
|
||||
"showLess": "Show less",
|
||||
"showMore": "Show more",
|
||||
"noBooksFound": "No books found for this series.",
|
||||
"selected": "selected",
|
||||
"loadingSeriesDetails": "Loading series details...",
|
||||
"status": {
|
||||
"unread": "UNREAD",
|
||||
"reading": "READING",
|
||||
"reReading": "RE-READING",
|
||||
"read": "READ",
|
||||
"partiallyRead": "PARTIALLY READ",
|
||||
"paused": "PAUSED",
|
||||
"abandoned": "ABANDONED",
|
||||
"wontRead": "WON'T READ",
|
||||
"unset": "UNSET"
|
||||
},
|
||||
"tooltip": {
|
||||
"metadataActions": "Metadata actions",
|
||||
"assignToShelf": "Assign to shelf",
|
||||
"lockUnlockMetadata": "Lock/Unlock metadata",
|
||||
"organizeFiles": "Organize Files",
|
||||
"moreActions": "More actions",
|
||||
"selectAll": "Select all books",
|
||||
"deselectAll": "Deselect all books",
|
||||
"deleteSelected": "Delete selected books"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,12 @@ import libraryCreator from './library-creator.json';
|
||||
import bookdrop from './bookdrop.json';
|
||||
import metadata from './metadata.json';
|
||||
import notebook from './notebook.json';
|
||||
import book from './book.json';
|
||||
import readerAudiobook from './reader-audiobook.json';
|
||||
import readerCbx from './reader-cbx.json';
|
||||
import readerEbook from './reader-ebook.json';
|
||||
|
||||
// To add a new domain: create the JSON file and add it here.
|
||||
// Settings tabs each get their own file: settings-email, settings-reader, settings-view, etc.
|
||||
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook};
|
||||
const translations: Translation = {common, auth, nav, dashboard, settings, settingsEmail, settingsReader, settingsView, settingsMeta, settingsLibMeta, settingsApp, settingsUsers, settingsNaming, settingsOpds, settingsTasks, settingsAuth, settingsDevice, settingsProfile, app, shared, layout, libraryCreator, bookdrop, metadata, notebook, book, readerAudiobook, readerCbx, readerEbook};
|
||||
export default translations;
|
||||
|
||||
64
booklore-ui/src/i18n/en/reader-audiobook.json
Normal file
64
booklore-ui/src/i18n/en/reader-audiobook.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"loading": "Loading audiobook...",
|
||||
"untitled": "Untitled",
|
||||
"unknownAuthor": "Unknown Author",
|
||||
"coverAlt": "Cover",
|
||||
"header": {
|
||||
"backTooltip": "Back",
|
||||
"bookmarksTooltip": "Bookmarks",
|
||||
"chaptersTracksTooltip": "Chapters/Tracks"
|
||||
},
|
||||
"trackInfo": {
|
||||
"trackOf": "Track {{ current }} of {{ total }}",
|
||||
"chapterOf": "Chapter {{ current }} of {{ total }}"
|
||||
},
|
||||
"controls": {
|
||||
"previousTrackTooltip": "Previous Track",
|
||||
"previousChapterTooltip": "Previous Chapter",
|
||||
"rewindTooltip": "-30s",
|
||||
"forwardTooltip": "+30s",
|
||||
"nextTrackTooltip": "Next Track",
|
||||
"nextChapterTooltip": "Next Chapter"
|
||||
},
|
||||
"extra": {
|
||||
"addBookmark": "Add Bookmark",
|
||||
"sleepTimer": "Sleep Timer",
|
||||
"endOfChapter": "End of chapter"
|
||||
},
|
||||
"details": {
|
||||
"narratedBy": "Narrated by {{ narrator }}",
|
||||
"kbps": "{{ bitrate }} kbps",
|
||||
"totalDuration": "{{ duration }} total"
|
||||
},
|
||||
"sidebar": {
|
||||
"tracks": "Tracks",
|
||||
"chapters": "Chapters"
|
||||
},
|
||||
"bookmarks": {
|
||||
"title": "Bookmarks",
|
||||
"empty": "No bookmarks yet",
|
||||
"emptyHint": "Add a bookmark to save your place",
|
||||
"deleteTooltip": "Delete",
|
||||
"bookmarkAt": "Bookmark at {{ time }}"
|
||||
},
|
||||
"sleepTimerMenu": {
|
||||
"minutes15": "15 minutes",
|
||||
"minutes30": "30 minutes",
|
||||
"minutes45": "45 minutes",
|
||||
"minutes60": "60 minutes",
|
||||
"endOfChapter": "End of chapter",
|
||||
"cancelTimer": "Cancel timer"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load audiobook",
|
||||
"audioLoadFailed": "Failed to load audio",
|
||||
"sleepTimerSet": "Playback will stop in {{ minutes }} minutes",
|
||||
"sleepTimerEndOfChapter": "Playback will stop at end of chapter",
|
||||
"sleepTimerStopped": "Playback stopped by sleep timer",
|
||||
"bookmarkAdded": "Bookmark Added",
|
||||
"bookmarkExists": "Bookmark Exists",
|
||||
"bookmarkExistsDetail": "A bookmark already exists at this position",
|
||||
"bookmarkFailed": "Failed to add bookmark",
|
||||
"bookmarkDeleted": "Bookmark Deleted"
|
||||
}
|
||||
}
|
||||
127
booklore-ui/src/i18n/en/reader-cbx.json
Normal file
127
booklore-ui/src/i18n/en/reader-cbx.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"reader": {
|
||||
"continueToNextBook": "Continue to Next Book",
|
||||
"noPagesAvailable": "No pages available.",
|
||||
"loadingBook": "Loading book...",
|
||||
"slideshow": "Slideshow"
|
||||
},
|
||||
"noteDialog": {
|
||||
"editNote": "Edit Note",
|
||||
"addNote": "Add Note",
|
||||
"pageLabel": "Page",
|
||||
"pageInfo": "Page {{ pageNumber }}",
|
||||
"yourNote": "Your Note",
|
||||
"placeholder": "Write your note here...",
|
||||
"noteColor": "Note Color",
|
||||
"updateNote": "Update Note",
|
||||
"saveNote": "Save Note",
|
||||
"colorAmber": "Amber",
|
||||
"colorGreen": "Green",
|
||||
"colorBlue": "Blue",
|
||||
"colorPink": "Pink",
|
||||
"colorPurple": "Purple",
|
||||
"colorDeepOrange": "Deep Orange"
|
||||
},
|
||||
"shortcutsHelp": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"gotIt": "Got it",
|
||||
"groupNavigation": "Navigation",
|
||||
"groupDisplay": "Display",
|
||||
"groupPlayback": "Playback",
|
||||
"groupOther": "Other",
|
||||
"previousNextPage": "Previous / Next page",
|
||||
"swipeLeftRight": "Swipe left/right",
|
||||
"nextPage": "Next page",
|
||||
"previousPage": "Previous page",
|
||||
"firstPage": "First page",
|
||||
"lastPage": "Last page",
|
||||
"toggleFullscreen": "Toggle fullscreen",
|
||||
"toggleReadingDirection": "Toggle reading direction (LTR/RTL)",
|
||||
"exitFullscreenCloseDialogs": "Exit fullscreen / Close dialogs",
|
||||
"toggleZoom": "Toggle zoom (fit page / actual size)",
|
||||
"doubleTap": "Double-tap",
|
||||
"toggleSlideshow": "Toggle slideshow / auto-play",
|
||||
"showHelpDialog": "Show this help dialog"
|
||||
},
|
||||
"footer": {
|
||||
"prevBook": "Prev Book",
|
||||
"nextBook": "Next Book",
|
||||
"firstPage": "First Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"lastPage": "Last Page",
|
||||
"of": "of",
|
||||
"pageSlider": "Page Slider",
|
||||
"pagePlaceholder": "Page",
|
||||
"go": "Go",
|
||||
"noPreviousBook": "No Previous Book",
|
||||
"noNextBook": "No Next Book",
|
||||
"previousBookTooltip": "Previous: {{ title }}",
|
||||
"nextBookTooltip": "Next: {{ title }}"
|
||||
},
|
||||
"header": {
|
||||
"contents": "Contents",
|
||||
"addBookmark": "Add Bookmark",
|
||||
"removeBookmark": "Remove Bookmark",
|
||||
"addNote": "Add Note",
|
||||
"pageHasNotesAddAnother": "Page has notes - Add another",
|
||||
"stopSlideshow": "Stop Slideshow (P)",
|
||||
"startSlideshow": "Start Slideshow (P)",
|
||||
"exitFullscreen": "Exit Fullscreen (F)",
|
||||
"fullscreen": "Fullscreen (F)",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts (?)",
|
||||
"more": "More",
|
||||
"stopSlideshowLabel": "Stop Slideshow",
|
||||
"startSlideshowLabel": "Start Slideshow",
|
||||
"exitFullscreenLabel": "Exit Fullscreen",
|
||||
"fullscreenLabel": "Fullscreen",
|
||||
"keyboardShortcutsLabel": "Keyboard Shortcuts",
|
||||
"settings": "Settings",
|
||||
"closeReader": "Close Reader"
|
||||
},
|
||||
"quickSettings": {
|
||||
"fitMode": "Fit Mode",
|
||||
"fitPage": "Fit Page",
|
||||
"fitWidth": "Fit Width",
|
||||
"fitHeight": "Fit Height",
|
||||
"actualSize": "Actual Size",
|
||||
"automatic": "Automatic",
|
||||
"scrollMode": "Scroll Mode",
|
||||
"paginated": "Paginated",
|
||||
"infinite": "Infinite",
|
||||
"longStrip": "Long Strip",
|
||||
"pageView": "Page View",
|
||||
"twoPage": "Two-Page",
|
||||
"single": "Single",
|
||||
"pageSpread": "Page Spread",
|
||||
"oddFirst": "Odd First",
|
||||
"evenFirst": "Even First",
|
||||
"readingDirection": "Reading Direction",
|
||||
"leftToRight": "Left to Right",
|
||||
"rightToLeft": "Right to Left",
|
||||
"slideshowInterval": "Slideshow Interval",
|
||||
"background": "Background",
|
||||
"black": "Black",
|
||||
"gray": "Gray",
|
||||
"white": "White"
|
||||
},
|
||||
"sidebar": {
|
||||
"contentTab": "Content",
|
||||
"bookmarksTab": "Bookmarks",
|
||||
"notesTab": "Notes",
|
||||
"noPagesFound": "No pages found",
|
||||
"noBookmarksYet": "No bookmarks yet",
|
||||
"bookmarkHint": "Tap the bookmark icon to save your place",
|
||||
"searchNotesPlaceholder": "Search notes...",
|
||||
"noMatchingNotes": "No matching notes",
|
||||
"tryDifferentSearch": "Try different search terms",
|
||||
"noNotesYet": "No notes yet",
|
||||
"notesHint": "Tap the notes icon to add a note for the current page",
|
||||
"deleteBookmark": "Delete bookmark",
|
||||
"editNote": "Edit note",
|
||||
"deleteNote": "Delete note",
|
||||
"clearSearch": "Clear search",
|
||||
"page": "Page",
|
||||
"untitled": "Untitled"
|
||||
}
|
||||
}
|
||||
194
booklore-ui/src/i18n/en/reader-ebook.json
Normal file
194
booklore-ui/src/i18n/en/reader-ebook.json
Normal file
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"reader": {
|
||||
"loadingBook": "Loading book..."
|
||||
},
|
||||
"metadataDialog": {
|
||||
"title": "Book Information",
|
||||
"basicInformation": "Basic Information",
|
||||
"titleLabel": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"authors": "Author(s)",
|
||||
"publisher": "Publisher",
|
||||
"published": "Published",
|
||||
"language": "Language",
|
||||
"pages": "Pages",
|
||||
"unknown": "Unknown",
|
||||
"na": "N/A",
|
||||
"series": "Series",
|
||||
"seriesName": "Series Name",
|
||||
"bookNumber": "Book Number",
|
||||
"identifiers": "Identifiers",
|
||||
"ratings": "Ratings",
|
||||
"reviews": "reviews",
|
||||
"categories": "Categories",
|
||||
"tags": "Tags",
|
||||
"fileInformation": "File Information",
|
||||
"fileSize": "File Size",
|
||||
"fileName": "File Name",
|
||||
"description": "Description"
|
||||
},
|
||||
"noteDialog": {
|
||||
"editNote": "Edit Note",
|
||||
"addNote": "Add Note",
|
||||
"selectedText": "Selected Text",
|
||||
"yourNote": "Your Note",
|
||||
"notePlaceholder": "Write your note here...",
|
||||
"noteColor": "Note Color",
|
||||
"updateNote": "Update Note",
|
||||
"saveNote": "Save Note"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"themeTab": "Theme",
|
||||
"typographyTab": "Typography",
|
||||
"layoutTab": "Layout",
|
||||
"darkMode": "Dark Mode",
|
||||
"themeColors": "Theme Colors",
|
||||
"annotationHighlighter": "Annotation Highlighter",
|
||||
"fontSettings": "Font Settings",
|
||||
"fontSize": "Font Size",
|
||||
"lineHeight": "Line Height",
|
||||
"fontFamily": "Font Family",
|
||||
"layout": "Layout",
|
||||
"readingFlow": "Reading Flow",
|
||||
"paginated": "Paginated",
|
||||
"scrolled": "Scrolled",
|
||||
"maxColumns": "Max Columns",
|
||||
"columnGap": "Column Gap",
|
||||
"maxWidth": "Max Width",
|
||||
"maxHeight": "Max Height",
|
||||
"textOptions": "Text Options",
|
||||
"justifyText": "Justify Text",
|
||||
"hyphenate": "Hyphenate"
|
||||
},
|
||||
"footer": {
|
||||
"previousSection": "Previous Section",
|
||||
"nextSection": "Next Section",
|
||||
"location": "Location",
|
||||
"progress": "Progress",
|
||||
"timeLeftInSection": "Time Left in Section",
|
||||
"timeLeftInBook": "Time Left in Book",
|
||||
"chapter": "Chapter",
|
||||
"section": "Section",
|
||||
"page": "Page",
|
||||
"goTo": "Go to",
|
||||
"go": "Go",
|
||||
"goToPercentage": "Go to percentage",
|
||||
"firstSection": "First Section",
|
||||
"lastSection": "Last Section",
|
||||
"jumpTo": "Jump To..."
|
||||
},
|
||||
"header": {
|
||||
"chapters": "Chapters",
|
||||
"addBookmark": "Add Bookmark",
|
||||
"removeBookmark": "Remove Bookmark",
|
||||
"search": "Search",
|
||||
"notes": "Notes",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"more": "More",
|
||||
"settings": "Settings",
|
||||
"closeReader": "Close Reader"
|
||||
},
|
||||
"quickSettings": {
|
||||
"darkMode": "Dark Mode",
|
||||
"fontSize": "Font Size",
|
||||
"lineSpacing": "Line Spacing",
|
||||
"moreSettings": "More Settings"
|
||||
},
|
||||
"panel": {
|
||||
"searchTitle": "Search",
|
||||
"notesTitle": "Notes",
|
||||
"searchTab": "Search",
|
||||
"notesTab": "Notes",
|
||||
"searchPlaceholder": "Search in book...",
|
||||
"clearSearch": "Clear search",
|
||||
"searching": "Searching...",
|
||||
"cancelSearch": "Cancel search",
|
||||
"resultsFound": "{{ count }} result found",
|
||||
"resultsFoundPlural": "{{ count }} results found",
|
||||
"noResultsFound": "No results found",
|
||||
"tryDifferentKeywords": "Try different keywords",
|
||||
"searchThisBook": "Search this book",
|
||||
"enterTextToFind": "Enter text to find in the book",
|
||||
"searchNotes": "Search notes...",
|
||||
"editNote": "Edit note",
|
||||
"deleteNote": "Delete note",
|
||||
"noMatchingNotes": "No matching notes",
|
||||
"tryDifferentSearchTerms": "Try different search terms",
|
||||
"noNotesYet": "No notes yet",
|
||||
"noNotesHint": "Select text and tap the note icon to add a note"
|
||||
},
|
||||
"sidebar": {
|
||||
"bookCoverAlt": "Book cover",
|
||||
"contentsTab": "Contents",
|
||||
"bookmarksTab": "Bookmarks",
|
||||
"highlightsTab": "Highlights",
|
||||
"deleteBookmark": "Delete bookmark",
|
||||
"noBookmarksYet": "No bookmarks yet",
|
||||
"noBookmarksHint": "Tap the bookmark icon to save your place",
|
||||
"deleteHighlight": "Delete highlight",
|
||||
"noHighlightsYet": "No highlights yet",
|
||||
"noHighlightsHint": "Select text to create a highlight"
|
||||
},
|
||||
"selectionPopup": {
|
||||
"copyText": "Copy Text",
|
||||
"searchInBook": "Search in book",
|
||||
"annotate": "Annotate",
|
||||
"addNote": "Add Note",
|
||||
"deleteAnnotation": "Delete Annotation"
|
||||
},
|
||||
"shortcutsHelp": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"gotIt": "Got it",
|
||||
"navigation": "Navigation",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"firstSection": "First section",
|
||||
"lastSection": "Last section",
|
||||
"panels": "Panels",
|
||||
"tableOfContents": "Table of contents",
|
||||
"searchShortcut": "Search",
|
||||
"notesShortcut": "Notes",
|
||||
"display": "Display",
|
||||
"toggleFullscreen": "Toggle fullscreen",
|
||||
"exitFullscreenCloseDialogs": "Exit fullscreen / Close dialogs",
|
||||
"other": "Other",
|
||||
"showHelpDialog": "Show this help dialog",
|
||||
"swipeRight": "Swipe right",
|
||||
"swipeLeft": "Swipe left"
|
||||
},
|
||||
"headerFooterUtil": {
|
||||
"timeRemainingInSection": "Time remaining in section: {{ time }}"
|
||||
},
|
||||
"toast": {
|
||||
"noteSavedSummary": "Note Saved",
|
||||
"noteSavedDetail": "Your note has been saved successfully.",
|
||||
"noteUpdatedSummary": "Note Updated",
|
||||
"noteUpdatedDetail": "Your note has been updated successfully.",
|
||||
"saveFailedSummary": "Save Failed",
|
||||
"saveFailedDetail": "Failed to save the note. Please try again.",
|
||||
"updateFailedSummary": "Update Failed",
|
||||
"updateFailedDetail": "Failed to update the note. Please try again.",
|
||||
"bookmarkAddedSummary": "Bookmark Added",
|
||||
"bookmarkAddedDetail": "Your bookmark was added successfully.",
|
||||
"bookmarkExistsSummary": "Bookmark Already Exists",
|
||||
"bookmarkExistsDetail": "You already have a bookmark at this location.",
|
||||
"bookmarkFailedSummary": "Unable to Add Bookmark",
|
||||
"bookmarkFailedDetail": "Something went wrong while adding the bookmark. Please try again.",
|
||||
"highlightAddedSummary": "Highlight Added",
|
||||
"highlightAddedDetail": "Your highlight was saved successfully.",
|
||||
"highlightExistsSummary": "Highlight Already Exists",
|
||||
"highlightExistsDetail": "You already have a highlight at this location.",
|
||||
"highlightFailedSummary": "Unable to Add Highlight",
|
||||
"highlightFailedDetail": "Something went wrong while adding the highlight. Please try again.",
|
||||
"highlightRemovedSummary": "Highlight Removed",
|
||||
"highlightRemovedDetail": "Your highlight was removed successfully.",
|
||||
"highlightRemoveFailedSummary": "Unable to Remove Highlight",
|
||||
"highlightRemoveFailedDetail": "Something went wrong while removing the highlight. Please try again.",
|
||||
"noteAnnotationUpdatedSummary": "Note Updated",
|
||||
"noteAnnotationUpdatedDetail": "Your note was saved successfully.",
|
||||
"noteAnnotationUpdateFailedSummary": "Unable to Update Note",
|
||||
"noteAnnotationUpdateFailedDetail": "Something went wrong while updating the note. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,10 @@
|
||||
"black": "Black",
|
||||
"white": "White"
|
||||
},
|
||||
"toast": {
|
||||
"preferencesUpdated": "Preferences Updated",
|
||||
"preferencesUpdatedDetail": "Your preferences have been saved successfully."
|
||||
},
|
||||
"fonts": {
|
||||
"sectionTitle": "Custom Font Library",
|
||||
"sectionDesc": "Personalize your reading experience by uploading custom fonts to use with eBook formats (EPUB, FB2, MOBI, AZW3). Upload up to 10 custom font files that will be available for selection in the eBook reader settings.",
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
"cancelFailed": "Cancellation Failed",
|
||||
"cancelError": "Failed to cancel the task. The task may already be completed or failed.",
|
||||
"cancelNoId": "Cannot cancel task without ID.",
|
||||
"loadError": "Failed to load tasks"
|
||||
"loadError": "Failed to load tasks",
|
||||
"metadataScheduled": "Metadata Update Scheduled",
|
||||
"metadataScheduledDetail": "The metadata update for the selected books has been successfully scheduled.",
|
||||
"metadataAlreadyRunningDetail": "A metadata refresh task is already in progress. Please wait for it to complete before starting another one.",
|
||||
"metadataFailed": "Metadata Update Failed",
|
||||
"metadataFailedDetail": "An unexpected error occurred while scheduling the metadata update. Please try again later or contact support if the issue persists."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,26 @@
|
||||
"foldersWillBeSelected": "{{ count }} folders will be selected",
|
||||
"selectDirectoriesBtn": "Select Directories"
|
||||
},
|
||||
"liveNotification": {
|
||||
"defaultMessage": "No recent notifications..."
|
||||
},
|
||||
"metadataProgress": {
|
||||
"taskStalled": "Task stalled or backend unavailable",
|
||||
"taskCancelled": "Task cancelled by user",
|
||||
"cancellationScheduledSummary": "Cancellation Scheduled",
|
||||
"cancellationScheduledDetail": "Task cancellation has been successfully scheduled",
|
||||
"cancelFailedSummary": "Cancel Failed",
|
||||
"cancelFailedDetail": "Failed to cancel the task. Please try again."
|
||||
},
|
||||
"reader": {
|
||||
"failedToLoadPages": "Failed to load pages",
|
||||
"failedToLoadBook": "Failed to load the book"
|
||||
},
|
||||
"settingsHelper": {
|
||||
"settingsSavedSummary": "Settings Saved",
|
||||
"settingsSavedDetail": "The settings were successfully saved!",
|
||||
"saveErrorDetail": "There was an error saving the settings."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Welcome to Booklore",
|
||||
"subtitle": "Setup your initial admin account to get started",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"oidc": {
|
||||
"loginFailedSummary": "Error de inicio de sesión OIDC",
|
||||
"redirectingDetail": "Redirigiendo al inicio de sesión local..."
|
||||
},
|
||||
"login": {
|
||||
"title": "Bienvenido de nuevo",
|
||||
"subtitle": "Inicia sesión para continuar tu viaje",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user