243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
'use client';
|
|
|
|
import { useParams } from "next/navigation";
|
|
import Link from 'next/link';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useEPUB } from '@/contexts/EPUBContext';
|
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
|
import { EPUBViewer } from '@/components/EPUBViewer';
|
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
|
import { SettingsIcon } from '@/components/icons/Icons';
|
|
import { Header } from '@/components/Header';
|
|
import { useTTS } from "@/contexts/TTSContext";
|
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
import { ZoomControl } from '@/components/ZoomControl';
|
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
|
import { DownloadIcon } from '@/components/icons/Icons';
|
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
|
import { SummarizeButton } from '@/components/SummarizeButton';
|
|
import { SummarizeModal } from '@/components/SummarizeModal';
|
|
import type { SummarizeMode } from '@/types/summary';
|
|
|
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
|
|
|
export default function EPUBPage() {
|
|
const { id } = useParams();
|
|
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, bookRef, renditionRef, extractPageText, currDocPages } = useEPUB();
|
|
const { stop } = useTTS();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
|
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
|
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
|
|
|
const loadDocument = useCallback(async () => {
|
|
console.log('Loading new epub (from page.tsx)');
|
|
stop(); // Reset TTS when loading new document
|
|
|
|
try {
|
|
if (!id) {
|
|
setError('Document not found');
|
|
return;
|
|
}
|
|
await setCurrentDocument(id as string);
|
|
} catch (err) {
|
|
console.error('Error loading document:', err);
|
|
setError('Failed to load document');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [id, setCurrentDocument, stop]);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading) return;
|
|
|
|
loadDocument();
|
|
}, [loadDocument, isLoading]);
|
|
|
|
// Compute available height = viewport - (header height + tts bar height)
|
|
useEffect(() => {
|
|
const compute = () => {
|
|
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
|
const ttsbar = document.querySelector('[data-app-ttsbar]') as HTMLElement | null;
|
|
const headerH = header ? header.getBoundingClientRect().height : 0;
|
|
const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0;
|
|
const vh = window.innerHeight;
|
|
const h = Math.max(0, vh - headerH - ttsH);
|
|
setContainerHeight(`${h}px`);
|
|
|
|
// compute max horizontal padding while preserving a minimum readable width,
|
|
// but still allow some padding on small screens
|
|
const vw = window.innerWidth;
|
|
const desiredMin = 640; // target readable min width
|
|
const minContent = Math.min(desiredMin, Math.max(320, vw - 32));
|
|
const maxPad = Math.max(0, Math.floor((vw - minContent) / 2));
|
|
setMaxPadPx(maxPad);
|
|
};
|
|
compute();
|
|
window.addEventListener('resize', compute);
|
|
return () => window.removeEventListener('resize', compute);
|
|
}, []);
|
|
|
|
// Nudge EPUB renderer to reflow on horizontal padding changes
|
|
useEffect(() => {
|
|
// Some EPUB renderers listen to window resize; emit a synthetic event
|
|
window.dispatchEvent(new Event('resize'));
|
|
}, [padPct]);
|
|
|
|
const handleGenerateAudiobook = useCallback(async (
|
|
onProgress: (progress: number) => void,
|
|
signal: AbortSignal,
|
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
|
format: TTSAudiobookFormat
|
|
) => {
|
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
|
}, [createEPUBAudioBook, id]);
|
|
|
|
const handleRegenerateChapter = useCallback(async (
|
|
chapterIndex: number,
|
|
bookId: string,
|
|
format: TTSAudiobookFormat,
|
|
signal: AbortSignal
|
|
) => {
|
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
|
}, [regenerateEPUBChapter]);
|
|
|
|
const handleExtractTextForSummary = useCallback(async (mode: SummarizeMode): Promise<string> => {
|
|
const book = bookRef.current;
|
|
const rendition = renditionRef.current;
|
|
|
|
if (!book || !rendition) {
|
|
throw new Error('EPUB document not loaded');
|
|
}
|
|
|
|
if (mode === 'whole_book') {
|
|
// Extract text from all spine sections
|
|
const promises: Promise<string>[] = [];
|
|
const spine = book.spine;
|
|
|
|
spine.each((item: { href?: string }) => {
|
|
const url = item.href || '';
|
|
if (!url) return;
|
|
|
|
const promise = book.load(url)
|
|
.then((section) => (section as Document))
|
|
.then((section) => section.body?.textContent?.trim() || '')
|
|
.catch((err) => {
|
|
console.warn('Failed to extract text from section:', url, err);
|
|
return '';
|
|
});
|
|
|
|
promises.push(promise);
|
|
});
|
|
|
|
const textParts = await Promise.all(promises);
|
|
return textParts.filter(text => text).join('\n\n');
|
|
} else {
|
|
// Extract current page text
|
|
return extractPageText(book, rendition, false);
|
|
}
|
|
}, [bookRef, renditionRef, extractPageText]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
|
<p className="text-red-500 mb-4">{error}</p>
|
|
<Link
|
|
href="/"
|
|
onClick={() => clearCurrDoc()}
|
|
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
|
>
|
|
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Back to Documents
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Header
|
|
left={
|
|
<Link
|
|
href="/"
|
|
onClick={() => clearCurrDoc()}
|
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
|
aria-label="Back to documents"
|
|
>
|
|
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Documents
|
|
</Link>
|
|
}
|
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
|
right={
|
|
<div className="flex items-center gap-3">
|
|
<ZoomControl
|
|
value={padPct}
|
|
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
|
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
|
min={0}
|
|
max={100}
|
|
/>
|
|
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!bookRef.current} />
|
|
{isDev && (
|
|
<button
|
|
onClick={() => setIsAudiobookModalOpen(true)}
|
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
|
aria-label="Open audiobook export"
|
|
title="Export Audiobook"
|
|
>
|
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsSettingsOpen(true)}
|
|
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
|
aria-label="Open settings"
|
|
>
|
|
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" />
|
|
</button>
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
|
{isLoading ? (
|
|
<div className="p-4">
|
|
<DocumentSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
|
|
<EPUBViewer className="h-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isDev && (
|
|
<AudiobookExportModal
|
|
isOpen={isAudiobookModalOpen}
|
|
setIsOpen={setIsAudiobookModalOpen}
|
|
documentType="epub"
|
|
documentId={id as string}
|
|
onGenerateAudiobook={handleGenerateAudiobook}
|
|
onRegenerateChapter={handleRegenerateChapter}
|
|
/>
|
|
)}
|
|
<SummarizeModal
|
|
isOpen={isSummarizeModalOpen}
|
|
setIsOpen={setIsSummarizeModalOpen}
|
|
docId={id as string}
|
|
docType="epub"
|
|
totalPages={currDocPages}
|
|
onExtractText={handleExtractTextForSummary}
|
|
/>
|
|
<TTSPlayer />
|
|
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
|
</>
|
|
);
|
|
}
|