mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-06 00:59:54 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user