Add sequential next/previous book navigation from library, filtered, and search views (#1931)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2025-12-18 14:48:40 -07:00
committed by GitHub
parent 63f71d1fde
commit f869ac0ac4
10 changed files with 689 additions and 300 deletions

View File

@@ -50,6 +50,7 @@ import {GroupRule} from '../../../magic-shelf/component/magic-shelf-component';
import {TaskHelperService} from '../../../settings/task-management/task-helper.service';
import {FilterLabelHelper} from './filter-label.helper';
import {LoadingService} from '../../../../core/services/loading.service';
import {BookNavigationService} from '../../service/book-navigation.service';
export enum EntityType {
LIBRARY = 'Library',
@@ -118,10 +119,10 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
protected confirmationService = inject(ConfirmationService);
protected magicShelfService = inject(MagicShelfService);
protected bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
protected taskHelperService = inject(TaskHelperService);
private pageTitle = inject(PageTitleService);
private loadingService = inject(LoadingService);
protected taskHelperService = inject(TaskHelperService);
private bookNavigationService = inject(BookNavigationService);
bookState$: Observable<BookState> | undefined;
entity$: Observable<Library | Shelf | MagicShelf | null> | undefined;
@@ -584,6 +585,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
)
.subscribe(books => {
this.currentBooks = books;
this.bookNavigationService.setAvailableBookIds(books.map(book => book.id));
});
}

View File

@@ -22,6 +22,7 @@ import {ResetProgressTypes} from '../../../../../shared/constants/reset-progress
import {ReadStatusHelper} from '../../../helpers/read-status.helper';
import {BookDialogHelperService} from '../BookDialogHelperService';
import {TaskHelperService} from '../../../../settings/task-management/task-helper.service';
import {BookNavigationService} from '../../../service/book-navigation.service';
@Component({
selector: 'app-book-card',
@@ -61,6 +62,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
protected urlHelper = inject(UrlHelperService);
private confirmationService = inject(ConfirmationService);
private bookDialogHelperService = inject(BookDialogHelperService);
private bookNavigationService = inject(BookNavigationService);
private userPermissions: any;
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
@@ -110,7 +112,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
get displayTitle(): string | undefined {
return (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata?.seriesName : this.book.metadata?.title;
// return (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata.seriesName : this.book.metadata?.title;
}
onImageLoad(): void {
@@ -132,7 +133,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
onMenuToggle(event: Event, menu: TieredMenu): void {
menu.toggle(event);
// Load additional files if not already loaded and needed
if (!this.additionalFilesLoaded && !this.isSubMenuLoading && this.needsAdditionalFilesData()) {
this.isSubMenuLoading = true;
this.bookService.getBookByIdFromAPI(this.book.id, true).subscribe({
@@ -446,6 +446,11 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
}
openBookInfo(book: Book): void {
const allBookIds = this.bookNavigationService.getAvailableBookIds();
if (allBookIds.length > 0) {
this.bookNavigationService.setNavigationContext(allBookIds, book.id);
}
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/book', book.id], {
queryParams: {tab: 'view'}

View File

@@ -0,0 +1,86 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
export interface BookNavigationState {
bookIds: number[];
currentIndex: number;
}
@Injectable({
providedIn: 'root'
})
export class BookNavigationService {
private navigationState$ = new BehaviorSubject<BookNavigationState | null>(null);
private availableBookIds: number[] = [];
setAvailableBookIds(bookIds: number[]): void {
this.availableBookIds = bookIds;
}
getAvailableBookIds(): number[] {
return this.availableBookIds;
}
setNavigationContext(bookIds: number[], currentBookId: number): void {
const currentIndex = bookIds.indexOf(currentBookId);
if (currentIndex !== -1) {
this.navigationState$.next({bookIds, currentIndex});
} else {
this.navigationState$.next(null);
}
}
getNavigationState(): Observable<BookNavigationState | null> {
return this.navigationState$.asObservable();
}
canNavigatePrevious(): boolean {
const state = this.navigationState$.value;
return state !== null && state.currentIndex > 0;
}
canNavigateNext(): boolean {
const state = this.navigationState$.value;
return state !== null && state.currentIndex < state.bookIds.length - 1;
}
getPreviousBookId(): number | null {
const state = this.navigationState$.value;
if (state && state.currentIndex > 0) {
return state.bookIds[state.currentIndex - 1];
}
return null;
}
getNextBookId(): number | null {
const state = this.navigationState$.value;
if (state && state.currentIndex < state.bookIds.length - 1) {
return state.bookIds[state.currentIndex + 1];
}
return null;
}
updateCurrentBook(bookId: number): void {
const state = this.navigationState$.value;
if (state) {
const newIndex = state.bookIds.indexOf(bookId);
if (newIndex !== -1) {
this.navigationState$.next({
...state,
currentIndex: newIndex
});
}
}
}
getCurrentPosition(): { current: number; total: number } | null {
const state = this.navigationState$.value;
if (state) {
return {
current: state.currentIndex + 1,
total: state.bookIds.length
};
}
return null;
}
}

View File

@@ -20,11 +20,16 @@
</p-tablist>
<p-tabpanels class="tabpanels-responsive overflow-auto">
<p-tabpanel value="view">
<app-metadata-viewer [book$]="book$" [recommendedBooks]="recommendedBooks"></app-metadata-viewer>
<app-metadata-viewer
[book$]="book$"
[recommendedBooks]="recommendedBooks">
</app-metadata-viewer>
</p-tabpanel>
@if (admin || canEditMetadata) {
<p-tabpanel value="edit">
<app-metadata-editor [book$]="book$"></app-metadata-editor>
<app-metadata-editor
[book$]="book$">
</app-metadata-editor>
</p-tabpanel>
}
@if (admin || canEditMetadata) {

View File

@@ -335,184 +335,184 @@
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="reviews">Public Reviews</label>
<div class="flex withbutton">
<input pSize="small" pInputText class="w-full" [disabled]="true" value="No Value"/>
@if (!book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
}
@if (book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="isbn10">ISBN 10</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="isbn10" formControlName="isbn10" class="w-full"/>
@if (!book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
}
@if (book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="isbn13">ISBN 13</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="isbn13" formControlName="isbn13" class="w-full"/>
@if (!book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
}
@if (book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="asin">Amazon ASIN</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="asin" formControlName="asin" class="w-full"/>
@if (!book.metadata!['asinLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('asin')" severity="success"></p-button>
}
@if (book.metadata!['asinLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('asin')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="amazonRating">Amazon ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="amazonRating" formControlName="amazonRating" class="w-full"/>
@if (!book.metadata!['amazonRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('amazonRating')" severity="success"></p-button>
}
@if (book.metadata!['amazonRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('amazonRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="amazonRatingCount">Amazon #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="amazonRatingCount" formControlName="amazonReviewCount" class="w-full"/>
@if (!book.metadata!['amazonReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('amazonReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['amazonReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('amazonReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="googleId">Google Books ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="googleId" formControlName="googleId" class="w-full"/>
@if (!book.metadata!['googleIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('googleId')" severity="success"></p-button>
}
@if (book.metadata!['googleIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('googleId')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="reviews">Public Reviews</label>
<div class="flex withbutton">
<input pSize="small" pInputText class="w-full" [disabled]="true" value="No Value"/>
@if (!book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
}
@if (book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
}
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsId">Goodreads ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsId" formControlName="goodreadsId" class="w-full"/>
@if (!book.metadata!['goodreadsIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsId')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsId')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsRating">Goodreads ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsRating" formControlName="goodreadsRating" class="w-full"/>
@if (!book.metadata!['goodreadsRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsRating')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsReviewCount">Goodreads #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsReviewCount" formControlName="goodreadsReviewCount" class="w-full"/>
@if (!book.metadata!['goodreadsReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverId">Hardcover ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverId" formControlName="hardcoverId" class="w-full"/>
@if (!book.metadata!['hardcoverIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverId')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverId')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverRating">Hardcover ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverRating" formControlName="hardcoverRating" class="w-full"/>
@if (!book.metadata!['hardcoverRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverRating')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverReviewCount">Hardcover #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverReviewCount" formControlName="hardcoverReviewCount" class="w-full"/>
@if (!book.metadata!['hardcoverReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="comicvineId">Comicvine ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="comicvineId" formControlName="comicvineId" class="w-full"/>
@if (!book.metadata!['comicvineIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('comicvineId')" severity="success"></p-button>
}
@if (book.metadata!['comicvineIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('comicvineId')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="isbn10">ISBN 10</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="isbn10" formControlName="isbn10" class="w-full"/>
@if (!book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
}
@if (book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="isbn13">ISBN 13</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="isbn13" formControlName="isbn13" class="w-full"/>
@if (!book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
}
@if (book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="asin">Amazon ASIN</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="asin" formControlName="asin" class="w-full"/>
@if (!book.metadata!['asinLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('asin')" severity="success"></p-button>
}
@if (book.metadata!['asinLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('asin')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="amazonRating">Amazon ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="amazonRating" formControlName="amazonRating" class="w-full"/>
@if (!book.metadata!['amazonRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('amazonRating')" severity="success"></p-button>
}
@if (book.metadata!['amazonRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('amazonRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="amazonRatingCount">Amazon #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="amazonRatingCount" formControlName="amazonReviewCount" class="w-full"/>
@if (!book.metadata!['amazonReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('amazonReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['amazonReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('amazonReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="googleId">Google Books ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="googleId" formControlName="googleId" class="w-full"/>
@if (!book.metadata!['googleIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('googleId')" severity="success"></p-button>
}
@if (book.metadata!['googleIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('googleId')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="flex flex-col flex-grow md:flex-row w-full gap-4 mt-4 mb-4">
<div class="flex flex-col gap-1 w-full h-full">
<label class="text-sm" for="description">Description</label>
<div class="flex flex-grow w-full withbutton">
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsId">Goodreads ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsId" formControlName="goodreadsId" class="w-full"/>
@if (!book.metadata!['goodreadsIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsId')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsId')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsRating">Goodreads ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsRating" formControlName="goodreadsRating" class="w-full"/>
@if (!book.metadata!['goodreadsRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsRating')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="goodreadsReviewCount">Goodreads #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="goodreadsReviewCount" formControlName="goodreadsReviewCount" class="w-full"/>
@if (!book.metadata!['goodreadsReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('goodreadsReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['goodreadsReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('goodreadsReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverId">Hardcover ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverId" formControlName="hardcoverId" class="w-full"/>
@if (!book.metadata!['hardcoverIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverId')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverId')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverRating">Hardcover ★</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverRating" formControlName="hardcoverRating" class="w-full"/>
@if (!book.metadata!['hardcoverRatingLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverRating')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverRatingLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverRating')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="hardcoverReviewCount">Hardcover #</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="hardcoverReviewCount" formControlName="hardcoverReviewCount" class="w-full"/>
@if (!book.metadata!['hardcoverReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverReviewCount')" severity="success"></p-button>
}
@if (book.metadata!['hardcoverReviewCountLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverReviewCount')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col gap-1 md:w-1/6">
<label class="text-sm" for="comicvineId">Comicvine ID</label>
<div class="flex withbutton">
<input pSize="small" pInputText id="comicvineId" formControlName="comicvineId" class="w-full"/>
@if (!book.metadata!['comicvineIdLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('comicvineId')" severity="success"></p-button>
}
@if (book.metadata!['comicvineIdLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('comicvineId')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="flex flex-col flex-grow md:flex-row w-full gap-4 mt-4 mb-4">
<div class="flex flex-col gap-1 w-full h-full">
<label class="text-sm" for="description">Description</label>
<div class="flex flex-grow w-full withbutton">
<textarea
id="description"
pTextarea
@@ -520,15 +520,15 @@
rows="10"
class="w-full h-full">
</textarea>
@if (!book.metadata!['descriptionLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('description')" severity="success"></p-button>
}
@if (book.metadata!['descriptionLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('description')" severity="warn"></p-button>
}
</div>
</div>
@if (!book.metadata!['descriptionLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('description')" severity="success"></p-button>
}
@if (book.metadata!['descriptionLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('description')" severity="warn"></p-button>
}
</div>
</div>
</div>
</div>
<div class="mt-auto">
<p-divider></p-divider>
@@ -545,6 +545,35 @@
</div>
}
@if (navigationState$ | async) {
<div class="flex gap-2 items-center">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
outlined
severity="info"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="info"
rounded
outlined
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
</div>
}
<div class="flex gap-x-4 items-center ml-auto">
<p-button
[label]="isAutoFetching ? 'Fetching' : 'Auto Fetch'"
@@ -573,8 +602,36 @@
<p-button size="small" icon="pi pi-arrow-right" [disabled]="disableNext" iconPos="right" [outlined]="true" severity="info" (onClick)="onNext()" pTooltip="Next Book" tooltipPosition="top"></p-button>
</div>
}
<div class="flex justify-between items-center w-full gap-4">
@if (navigationState$ | async) {
<div class="flex gap-2 items-center">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
outlined
severity="info"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="info"
rounded
outlined
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
</div>
}
<div class="flex gap-4">
<p-button
icon="pi pi-bolt"
@@ -617,6 +674,7 @@
</div>
</div>
</div>
</div>
</form>

View File

@@ -23,6 +23,10 @@ import {Image} from "primeng/image";
import {LazyLoadImageModule} from "ng-lazyload-image";
import {TaskHelperService} from '../../../../settings/task-management/task-helper.service';
import {BookDialogHelperService} from "../../../../book/components/book-browser/BookDialogHelperService";
import {BookNavigationService} from '../../../../book/service/book-navigation.service';
import {BookMetadataHostService} from '../../../../../shared/service/book-metadata-host-service';
import {Router} from '@angular/router';
import {UserService} from '../../../../settings/user-management/user.service';
@Component({
selector: "app-metadata-editor",
@@ -61,6 +65,10 @@ export class MetadataEditorComponent implements OnInit {
private taskHelperService = inject(TaskHelperService);
protected urlHelper = inject(UrlHelperService);
private bookDialogHelperService = inject(BookDialogHelperService);
private bookNavigationService = inject(BookNavigationService);
private metadataHostService = inject(BookMetadataHostService);
private router = inject(Router);
private userService = inject(UserService);
private destroyRef = inject(DestroyRef);
metadataForm: FormGroup;
@@ -86,6 +94,9 @@ export class MetadataEditorComponent implements OnInit {
filteredTags: string[] = [];
filteredPublishers: string[] = [];
filteredSeries: string[] = [];
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
navigationState$ = this.bookNavigationService.getNavigationState();
filterCategories(event: { query: string }) {
const query = event.query.toLowerCase();
@@ -206,6 +217,15 @@ export class MetadataEditorComponent implements OnInit {
});
this.prepareAutoComplete();
this.userService.userState$
.pipe(
filter(userState => !!userState?.user && userState.loaded),
take(1)
)
.subscribe(userState => {
this.metadataCenterViewMode = userState.user?.userSettings.metadataCenterViewMode ?? 'route';
});
}
private prepareAutoComplete(): void {
@@ -692,5 +712,43 @@ export class MetadataEditorComponent implements OnInit {
});
}
canNavigatePrevious(): boolean {
return this.bookNavigationService.canNavigatePrevious();
}
canNavigateNext(): boolean {
return this.bookNavigationService.canNavigateNext();
}
navigatePrevious(): void {
const prevBookId = this.bookNavigationService.getPreviousBookId();
if (prevBookId) {
this.navigateToBook(prevBookId);
}
}
navigateNext(): void {
const nextBookId = this.bookNavigationService.getNextBookId();
if (nextBookId) {
this.navigateToBook(nextBookId);
}
}
private navigateToBook(bookId: number): void {
this.bookNavigationService.updateCurrentBook(bookId);
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/book', bookId], {
queryParams: {tab: 'edit'}
});
} else {
this.metadataHostService.switchBook(bookId);
}
}
getNavigationPosition(): string {
const position = this.bookNavigationService.getCurrentPosition();
return position ? `${position.current} of ${position.total}` : '';
}
protected readonly sample = sample;
}

View File

@@ -46,6 +46,35 @@
</p-progressBar>
}
</div>
@if (navigationState$ | async) {
<div class="navigation-buttons-mobile">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
outlined
severity="info"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="info"
rounded
outlined
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
</div>
}
</div>
<div class="info-section">
@@ -454,6 +483,35 @@
@if (userService.userState$ | async; as userState) {
<div class="action-buttons">
@if (navigationState$ | async) {
<div class="navigation-buttons-desktop">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
outlined
severity="info"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="info"
rounded
outlined
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
</div>
<p-divider layout="vertical" class="action-divider"></p-divider>
}
@if (book!.bookType === 'PDF') {
@if (readMenuItems$ | async; as readItems) {
<p-splitbutton label="Read" icon="pi pi-book" [model]="readItems" (onClick)="read(book.id, 'ngx')" severity="primary"/>
@@ -462,19 +520,14 @@
@if (book!.bookType !== 'PDF' && book!.bookType !== 'FB2') {
<p-button label="Read" icon="pi pi-book" (onClick)="read(book?.metadata!.bookId, undefined)" severity="primary"/>
}
<p-button label="Shelf" icon="pi pi-folder" severity="secondary" outlined (onClick)="assignShelf(book.id)"></p-button>
<p-button label="Shelf" icon="pi pi-folder" severity="info" outlined (onClick)="assignShelf(book.id)" class="mobile-icon-only"></p-button>
@if (userState.user!.permissions.canDownload || userState.user!.permissions.admin) {
@if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) {
@if (downloadMenuItems$ | async; as downloadItems) {
<p-splitbutton label="Download" icon="pi pi-download" [model]="downloadItems" (onClick)="download(book)" severity="success" outlined/>
<p-splitbutton label="Download" icon="pi pi-download" [model]="downloadItems" (onClick)="download(book)" severity="success" outlined class="mobile-icon-only"/>
}
} @else {
<p-button label="Download" icon="pi pi-download" severity="success" outlined (onClick)="download(book)"></p-button>
}
}
@if (userState.user!.permissions.canEmailBook || userState.user!.permissions.admin) {
@if (emailMenuItems$ | async; as emailItems) {
<p-splitbutton label="Quick Send" icon="pi pi-send" [model]="emailItems" (onClick)="quickSend(book!.id)" outlined severity="info"></p-splitbutton>
<p-button label="Download" icon="pi pi-download" severity="success" outlined (onClick)="download(book)" class="mobile-icon-only"></p-button>
}
}
@if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) {
@@ -488,7 +541,8 @@
(onClick)="quickRefresh(book!.id)"
[disabled]="isAutoFetching"
pTooltip="Automatically fetch metadata using default sources"
tooltipPosition="top">
tooltipPosition="top"
styleClass="mobile-icon-only">
</p-splitbutton>
}
}

View File

@@ -26,17 +26,21 @@
.cover-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
gap: 1rem;
@media (min-width: 768px) {
justify-content: flex-start;
align-items: flex-start;
}
}
.cover-wrapper {
position: relative;
width: 175px;
width: 250px;
overflow: hidden;
@media (min-width: 768px) {
@@ -461,6 +465,60 @@
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
::ng-deep .action-divider {
display: none;
@media (min-width: 768px) {
display: block;
}
}
@media (max-width: 767px) {
::ng-deep .mobile-icon-only .p-button-label {
display: none !important;
padding: 10px !important;
}
::ng-deep .mobile-icon-only .p-button {
min-width: auto !important;
padding: 10px !important;
}
::ng-deep .mobile-icon-only.p-splitbutton .p-button-label {
display: none !important;
padding: 10px !important;
}
::ng-deep .mobile-icon-only.p-splitbutton .p-button {
min-width: auto !important;
padding: 10px !important;
}
}
.navigation-buttons-desktop {
display: none;
align-items: center;
gap: 0.5rem;
}
.navigation-buttons-mobile {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
@media (min-width: 768px) {
.navigation-buttons-desktop {
display: flex;
}
.navigation-buttons-mobile {
display: none;
}
}
.description-section {

View File

@@ -38,13 +38,16 @@ import {
matchScoreRanges,
pageCountRanges
} from '../../../../book/components/book-browser/book-filter/book-filter.component';
import {BookNavigationService} from '../../../../book/service/book-navigation.service';
import {Divider} from 'primeng/divider';
import {BookMetadataHostService} from '../../../../../shared/service/book-metadata-host-service';
@Component({
selector: 'app-metadata-viewer',
standalone: true,
templateUrl: './metadata-viewer.component.html',
styleUrl: './metadata-viewer.component.scss',
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image, TagComponent, UpperCasePipe]
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image, TagComponent, UpperCasePipe, Divider]
})
export class MetadataViewerComponent implements OnInit, OnChanges {
@Input() book$!: Observable<Book | null>;
@@ -65,7 +68,6 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
private destroyRef = inject(DestroyRef);
private dialogRef?: DynamicDialogRef;
emailMenuItems$!: Observable<MenuItem[]>;
readMenuItems$!: Observable<MenuItem[]>;
refreshMenuItems$!: Observable<MenuItem[]>;
otherItems$!: Observable<MenuItem[]>;
@@ -91,20 +93,11 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
{value: ReadStatus.UNSET, label: 'Unset'},
];
ngOnInit(): void {
this.emailMenuItems$ = this.book$.pipe(
map(book => book?.metadata ?? null),
filter((metadata): metadata is BookMetadata => metadata != null),
map((metadata): MenuItem[] => [
{
label: 'Custom Send',
command: () => {
this.bookDialogHelperService.openCustomSendDialog(metadata.bookId);
}
}
])
);
private bookNavigationService = inject(BookNavigationService);
private metadataHostService = inject(BookMetadataHostService);
navigationState$ = this.bookNavigationService.getNavigationState();
ngOnInit(): void {
this.refreshMenuItems$ = this.book$.pipe(
filter((book): book is Book => book !== null),
map((book): MenuItem[] => [
@@ -171,95 +164,123 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
this.otherItems$ = this.book$.pipe(
filter((book): book is Book => book !== null),
map((book): MenuItem[] => {
const items: MenuItem[] = [
{
label: 'Upload File',
icon: 'pi pi-upload',
command: () => {
this.bookDialogHelperService.openAdditionalFileUploaderDialog(book);
},
},
{
label: 'Organize Files',
icon: 'pi pi-arrows-h',
command: () => {
this.openFileMoverDialog(book.id);
},
},
{
label: 'Delete Book',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([book.id])).subscribe({
next: () => {
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/dashboard']);
} else {
this.dialogRef?.close();
}
},
error: () => {
switchMap(book =>
this.userService.userState$.pipe(
take(1),
map(userState => {
const items: MenuItem[] = [
{
label: 'Upload File',
icon: 'pi pi-upload',
command: () => {
this.bookDialogHelperService.openAdditionalFileUploaderDialog(book);
},
},
{
label: 'Organize Files',
icon: 'pi pi-arrows-h',
command: () => {
this.openFileMoverDialog(book.id);
},
},
];
// Add Send Book submenu if user has permission
if (userState?.user?.permissions.canEmailBook || userState?.user?.permissions.admin) {
items.push({
label: 'Send Book',
icon: 'pi pi-send',
items: [
{
label: 'Quick Send',
icon: 'pi pi-bolt',
command: () => this.quickSend(book.id)
},
{
label: 'Custom Send',
icon: 'pi pi-cog',
command: () => {
this.bookDialogHelperService.openCustomSendDialog(book.id);
}
}
]
});
}
items.push({
label: 'Delete Book',
icon: 'pi pi-trash',
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to delete "${book.metadata?.title}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptIcon: 'pi pi-trash',
rejectIcon: 'pi pi-times',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.bookService.deleteBooks(new Set([book.id])).subscribe({
next: () => {
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/dashboard']);
} else {
this.dialogRef?.close();
}
},
error: () => {
}
});
}
});
},
});
// Add delete additional files menu if there are any additional files
if ((book.alternativeFormats && book.alternativeFormats.length > 0) ||
(book.supplementaryFiles && book.supplementaryFiles.length > 0)) {
const deleteFileItems: MenuItem[] = [];
// Add alternative formats
if (book.alternativeFormats && book.alternativeFormats.length > 0) {
book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
deleteFileItems.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, format.id, format.fileName || 'file')
});
}
});
}
// Add separator if both types exist
if (book.alternativeFormats && book.alternativeFormats.length > 0 &&
book.supplementaryFiles && book.supplementaryFiles.length > 0) {
deleteFileItems.push({separator: true});
}
// Add supplementary files
if (book.supplementaryFiles && book.supplementaryFiles.length > 0) {
book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
deleteFileItems.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, file.id, file.fileName || 'file')
});
});
}
items.push({
label: 'Delete Additional Files',
icon: 'pi pi-trash',
items: deleteFileItems
});
},
},
];
}
// Add delete additional files menu if there are any additional files
if ((book.alternativeFormats && book.alternativeFormats.length > 0) ||
(book.supplementaryFiles && book.supplementaryFiles.length > 0)) {
const deleteFileItems: MenuItem[] = [];
// Add alternative formats
if (book.alternativeFormats && book.alternativeFormats.length > 0) {
book.alternativeFormats.forEach(format => {
const extension = this.getFileExtension(format.filePath);
deleteFileItems.push({
label: `${format.fileName} (${this.getFileSizeInMB(format)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, format.id, format.fileName || 'file')
});
});
}
// Add separator if both types exist
if (book.alternativeFormats && book.alternativeFormats.length > 0 &&
book.supplementaryFiles && book.supplementaryFiles.length > 0) {
deleteFileItems.push({separator: true});
}
// Add supplementary files
if (book.supplementaryFiles && book.supplementaryFiles.length > 0) {
book.supplementaryFiles.forEach(file => {
const extension = this.getFileExtension(file.filePath);
deleteFileItems.push({
label: `${file.fileName} (${this.getFileSizeInMB(file)})`,
icon: this.getFileIcon(extension),
command: () => this.deleteAdditionalFile(book.id, file.id, file.fileName || 'file')
});
});
}
items.push({
label: 'Delete Additional Files',
icon: 'pi pi-trash',
items: deleteFileItems
});
}
return items;
})
return items;
})
)
)
);
this.userService.userState$
@@ -846,4 +867,42 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
protected readonly ResetProgressTypes = ResetProgressTypes;
protected readonly ReadStatus = ReadStatus;
canNavigatePrevious(): boolean {
return this.bookNavigationService.canNavigatePrevious();
}
canNavigateNext(): boolean {
return this.bookNavigationService.canNavigateNext();
}
navigatePrevious(): void {
const prevBookId = this.bookNavigationService.getPreviousBookId();
if (prevBookId) {
this.navigateToBook(prevBookId);
}
}
navigateNext(): void {
const nextBookId = this.bookNavigationService.getNextBookId();
if (nextBookId) {
this.navigateToBook(nextBookId);
}
}
private navigateToBook(bookId: number): void {
this.bookNavigationService.updateCurrentBook(bookId);
if (this.metadataCenterViewMode === 'route') {
this.router.navigate(['/book', bookId], {
queryParams: {tab: 'view'}
});
} else {
this.metadataHostService.switchBook(bookId);
}
}
getNavigationPosition(): string {
const position = this.bookNavigationService.getCurrentPosition();
return position ? `${position.current} of ${position.total}` : '';
}
}

View File

@@ -9,6 +9,10 @@ export class BookMetadataHostService {
this.bookSwitchRequest$.next(bookId);
}
switchBook(bookId: number): void {
this.bookSwitchRequest$.next(bookId);
}
get bookSwitches$() {
return this.bookSwitchRequest$.asObservable();
}