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

@@ -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'"
@@ -575,6 +604,34 @@
}
<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,7 +164,10 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
this.otherItems$ = this.book$.pipe(
filter((book): book is Book => book !== null),
map((book): MenuItem[] => {
switchMap(book =>
this.userService.userState$.pipe(
take(1),
map(userState => {
const items: MenuItem[] = [
{
label: 'Upload File',
@@ -187,7 +183,31 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
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: () => {
@@ -213,8 +233,7 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
}
});
},
},
];
});
// Add delete additional files menu if there are any additional files
if ((book.alternativeFormats && book.alternativeFormats.length > 0) ||
@@ -260,6 +279,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
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();
}