Feature: Next/previous book buttons on CBX reader (#1549)

* feat: add next book button to cbx reader

* chore: reduce redundant css elements

* chore: additional css cleanup
This commit is contained in:
CounterClops
2025-11-21 15:16:55 +08:00
committed by GitHub
parent f141fa49ba
commit 9eb0dabd5e
3 changed files with 310 additions and 4 deletions

View File

@@ -1,6 +1,15 @@
<div class="comic-reader-container" tabindex="0">
<div class="navigation">
<div class="goto-page-controls">
<button
*ngIf="currentBook?.metadata?.seriesName"
class="series-nav-button prev-book"
(click)="navigateToPreviousBook()"
[disabled]="!previousBookInSeries"
[title]="getPreviousBookTooltip()">
<span>← Previous Book</span>
</button>
<div class="input-group">
<input
type="number"
@@ -17,6 +26,15 @@
Go
</button>
</div>
<button
*ngIf="currentBook?.metadata?.seriesName"
class="series-nav-button next-book"
(click)="navigateToNextBook()"
[disabled]="!nextBookInSeries"
[title]="getNextBookTooltip()">
<span>Next Book →</span>
</button>
</div>
<div class="page-controls">
@@ -87,6 +105,22 @@
</button>
@if (showMobileOptionsDropdown) {
<div class="mobile-options-menu">
@if (currentBook?.metadata?.seriesName) {
<button
class="mobile-option"
(click)="navigateToPreviousBook(); toggleMobileOptionsDropdown()"
[disabled]="!previousBookInSeries">
<span class="option-icon">⬅️</span>
<span class="option-label">Previous Book</span>
</button>
<button
class="mobile-option"
(click)="navigateToNextBook(); toggleMobileOptionsDropdown()"
[disabled]="!nextBookInSeries">
<span class="option-icon">➡️</span>
<span class="option-label">Next Book</span>
</button>
}
<button class="mobile-option" (click)="selectMobileOption('fitMode')">
<span class="option-icon">{{ displayLabel }}</span>
<span class="option-label">Fit Mode</span>
@@ -150,6 +184,15 @@
<img [src]="url" alt="Page Image" class="page-image"/>
}
</div>
@if (isAtLastPage && nextBookInSeries) {
<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="book-title">{{ getBookDisplayTitle(nextBookInSeries) }}</span>
</button>
</div>
}
} @else {
<div class="infinite-scroll-wrapper">
@for (url of infiniteScrollImageUrls; track url; let i = $index) {
@@ -160,6 +203,15 @@
<p-progressSpinner class="small-spinner"></p-progressSpinner>
</div>
}
@if (infiniteScrollPages.length > 0 && infiniteScrollPages[infiniteScrollPages.length - 1] >= pages.length - 1 && nextBookInSeries) {
<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="book-title">{{ getBookDisplayTitle(nextBookInSeries) }}</span>
</button>
</div>
}
</div>
}
} @else {

View File

@@ -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;
}
}
}
}
}

View File

@@ -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)}`;
}
}