+
+
+
+
@@ -87,6 +105,22 @@
@if (showMobileOptionsDropdown) {
+ @if (isAtLastPage && nextBookInSeries) {
+
+
+
+ }
} @else {
}
+ @if (infiniteScrollPages.length > 0 && infiniteScrollPages[infiniteScrollPages.length - 1] >= pages.length - 1 && nextBookInSeries) {
+
+
+
+ }
}
} @else {
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
index 191b11d87..02924d625 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
+++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss
@@ -31,6 +31,7 @@
min-width: fit-content;
display: flex;
justify-content: flex-start;
+ gap: 0.5rem;
.input-group {
display: flex;
@@ -232,9 +233,19 @@
border-bottom: none;
}
- &:hover {
+ &:hover:not(:disabled) {
background: #3a3a3a;
}
+
+ &:disabled {
+ color: #666;
+ cursor: not-allowed;
+ opacity: 0.5;
+
+ .option-icon {
+ opacity: 0.5;
+ }
+ }
}
.submenu {
@@ -376,6 +387,24 @@
background: #0f0f0f;
min-height: 0;
position: relative;
+
+ &:not(.two-page-view):not(.infinite-scroll) {
+ flex-direction: column;
+ align-items: stretch;
+
+ .pages-wrapper {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 0;
+ }
+
+ .end-of-comic-action {
+ flex: 0 0 auto;
+ margin-top: 1rem;
+ }
+ }
&.bg-black {
background: #000000;
@@ -648,4 +677,111 @@
width: 40px;
height: 40px;
}
+
+ .goto-page-controls {
+ .series-nav-button {
+ padding: 6px 12px;
+ background: #4a90e2;
+ border-radius: 4px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: all 0.2s ease;
+
+ &:hover:not(:disabled) {
+ background: #357abd;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(74, 144, 226, 0.4);
+ }
+
+ &:disabled {
+ background: #555;
+ cursor: not-allowed;
+ opacity: 0.5;
+ color: #999;
+ }
+
+ span {
+ display: inline-flex;
+ align-items: center;
+ }
+ }
+ }
+
+ .end-of-comic-action {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 2rem 1rem;
+
+ .next-book-action-button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 2rem 3rem;
+ background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
+ border-radius: 12px;
+ font-weight: 600;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 20px rgba(74, 144, 226, 0.3);
+ min-width: 280px;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 30px rgba(74, 144, 226, 0.5);
+ background: linear-gradient(135deg, #357abd 0%, #2a6399 100%);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ .action-icon {
+ font-size: 2.5rem;
+ }
+
+ .action-text {
+ font-size: 1.1rem;
+ }
+
+ .book-title {
+ font-size: 0.95rem;
+ font-weight: 400;
+ opacity: 0.9;
+ max-width: 350px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+}
+
+@media (max-width: 768px) {
+ .comic-reader-container {
+ .goto-page-controls {
+ flex-wrap: wrap;
+ gap: 4px;
+
+ .series-nav-button {
+ display: none;
+ }
+ }
+
+ .end-of-comic-action {
+ padding: 1.5rem 1rem;
+
+ .next-book-action-button {
+ min-width: 240px;
+ padding: 1.5rem 2rem;
+
+ .action-icon { font-size: 2rem; }
+ .action-text { font-size: 1rem; }
+ .book-title {
+ font-size: 0.85rem;
+ max-width: 250px;
+ }
+ }
+ }
+ }
}
diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
index 0585a713c..0038ee585 100644
--- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
+++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts
@@ -1,5 +1,5 @@
import {Component, HostListener, inject, OnInit} from '@angular/core';
-import {ActivatedRoute} from '@angular/router';
+import {ActivatedRoute, Router} from '@angular/router';
import {PageTitleService} from "../../../shared/service/page-title.service";
import {CbxReaderService} from '../../book/service/cbx-reader.service';
import {BookService} from '../../book/service/book.service';
@@ -15,15 +15,18 @@ import {
} from '../../settings/user-management/user.service';
import {MessageService} from 'primeng/api';
import {forkJoin} from 'rxjs';
-import {BookSetting, BookType} from '../../book/model/book.model';
+import {filter, first, timeout} from 'rxjs/operators';
+import {Book, BookSetting, BookType} from '../../book/model/book.model';
+import {BookState} from '../../book/model/state/book-state.model';
import {ProgressSpinner} from 'primeng/progressspinner';
import {FormsModule} from "@angular/forms";
import {NewPdfReaderService} from '../../book/service/new-pdf-reader.service';
+import {NgIf} from '@angular/common';
@Component({
selector: 'app-cbx-reader',
standalone: true,
- imports: [ProgressSpinner, FormsModule],
+ imports: [ProgressSpinner, FormsModule, NgIf],
templateUrl: './cbx-reader.component.html',
styleUrl: './cbx-reader.component.scss'
})
@@ -44,7 +47,12 @@ export class CbxReaderComponent implements OnInit {
private touchStartX = 0;
private touchEndX = 0;
+ currentBook: Book | null = null;
+ nextBookInSeries: Book | null = null;
+ previousBookInSeries: Book | null = null;
+
private route = inject(ActivatedRoute);
+ private router = inject(Router);
private cbxReaderService = inject(CbxReaderService);
private pdfReaderService = inject(NewPdfReaderService);
private bookService = inject(BookService);
@@ -86,6 +94,10 @@ export class CbxReaderComponent implements OnInit {
this.isLoading = true;
this.bookId = +params.get('bookId')!;
+ this.previousBookInSeries = null;
+ this.nextBookInSeries = null;
+ this.currentBook = null;
+
forkJoin([
this.bookService.getBookByIdFromAPI(this.bookId, false),
this.bookService.getBookSetting(this.bookId),
@@ -94,9 +106,14 @@ export class CbxReaderComponent implements OnInit {
next: ([book, bookSettings, myself]) => {
const userSettings = myself.userSettings;
this.bookType = book.bookType;
+ this.currentBook = book;
this.pageTitle.setBookPageTitle(book);
+ if (book.metadata?.seriesName) {
+ this.loadSeriesNavigationAsync(book);
+ }
+
const pagesObservable = this.bookType === CbxReaderComponent.TYPE_PDF
? this.pdfReaderService.getAvailablePages(this.bookId)
: this.cbxReaderService.getAvailablePages(this.bookId);
@@ -562,4 +579,105 @@ export class CbxReaderComponent implements OnInit {
this.showFitModeSubmenu = false;
}
}
+
+ get isAtLastPage(): boolean {
+ return this.currentPage >= this.pages.length - 1;
+ }
+
+ navigateToPreviousBook(): void {
+ if (this.previousBookInSeries) {
+ this.router.navigate(['/cbx-reader/book', this.previousBookInSeries.id]);
+ }
+ }
+
+ navigateToNextBook(): void {
+ if (this.nextBookInSeries) {
+ this.router.navigate(['/cbx-reader/book', this.nextBookInSeries.id]);
+ }
+ }
+
+ private loadSeriesNavigationAsync(book: Book): void {
+ this.bookService.bookState$.pipe(
+ filter((state: BookState) => state.loaded),
+ first(),
+ timeout(10000)
+ ).subscribe({
+ next: () => {
+ this.loadSeriesNavigation(book);
+ },
+ error: (err) => {
+ console.warn('[SeriesNav] BookService state loading timed out or failed, series navigation will be disabled:', err);
+ }
+ });
+ }
+
+ private loadSeriesNavigation(book: Book): void {
+ this.bookService.getBooksInSeries(book.id).subscribe({
+ next: (seriesBooks) => {
+ const sortedBySeriesNumber = this.sortBooksBySeriesNumber(seriesBooks);
+ const currentBookIndex = sortedBySeriesNumber.findIndex(b => b.id === book.id);
+
+ if (currentBookIndex === -1) {
+ console.warn('[SeriesNav] Current book not found in series');
+ return;
+ }
+
+ const hasPreviousBook = currentBookIndex > 0;
+ const hasNextBook = currentBookIndex < sortedBySeriesNumber.length - 1;
+
+ this.previousBookInSeries = hasPreviousBook ? sortedBySeriesNumber[currentBookIndex - 1] : null;
+ this.nextBookInSeries = hasNextBook ? sortedBySeriesNumber[currentBookIndex + 1] : null;
+
+ console.log('[SeriesNav] Navigation loaded:', {
+ series: book.metadata?.seriesName,
+ totalBooks: seriesBooks.length,
+ currentPosition: currentBookIndex + 1,
+ hasPrevious: hasPreviousBook,
+ hasNext: hasNextBook
+ });
+ },
+ error: (err) => {
+ console.error('[SeriesNav] Failed to load series information:', err);
+ }
+ });
+ }
+
+ private sortBooksBySeriesNumber(books: Book[]): Book[] {
+ return books.sort((bookA, bookB) => {
+ const seriesNumberA = bookA.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER;
+ const seriesNumberB = bookB.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER;
+ return seriesNumberA - seriesNumberB;
+ });
+ }
+
+ getBookDisplayTitle(book: Book | null): string {
+ if (!book) return '';
+
+ const parts: string[] = [];
+
+ if (book.metadata?.seriesNumber) {
+ parts.push(`#${book.metadata.seriesNumber}`);
+ }
+
+ const title = book.metadata?.title || book.fileName;
+ if (title) {
+ parts.push(title);
+ }
+
+ if (book.metadata?.subtitle) {
+ parts.push(book.metadata.subtitle);
+ }
+
+ return parts.join(' - ');
+ }
+
+ getPreviousBookTooltip(): string {
+ if (!this.previousBookInSeries) return 'No Previous Book';
+ return `Previous Book: ${this.getBookDisplayTitle(this.previousBookInSeries)}`;
+ }
+
+ getNextBookTooltip(): string {
+ if (!this.nextBookInSeries) return 'No Next Book';
+ return `Next Book: ${this.getBookDisplayTitle(this.nextBookInSeries)}`;
+ }
}