From 42665884d74ea7c068e24195df568d940c193349 Mon Sep 17 00:00:00 2001 From: Richard Roberson Date: Tue, 11 Nov 2025 13:32:30 -0700 Subject: [PATCH] feat(audiobook): add chapter-based export with UI and API Introduce end-to-end chapterized audiobook generation with persistent storage, resumable workflows, and MP3/M4B support. API: - add /api/audio/convert/chapter (GET/DELETE) for per-chapter ops - add /api/audio/convert/chapters (GET/DELETE) for listing/reset - enhance /api/audio/convert: - accept mp3|m4b, stream combined file, cache complete output - robust chapter indexing, docstore persistence, list concat - AbortSignal-aware ffmpeg/ffprobe, 499 on cancel UI/UX: - add AudiobookExportModal with progress, resume, regenerate, download - add ProgressCard, enhance ProgressPopup (click-to-focus, richer info) - add Header, ZoomControl; move TTSPlayer to sticky bottom bar - redesign EPUB/HTML/PDF pages for full-height layout and controls - add HomeContent; compact uploader variant; document list polish TTS/Contexts: - EPUB/PDF contexts now generate per-chapter to disk, return bookId - support chapter regeneration; progress/cancel propagation - pass provider/model/instructions; standardize MP3 TTS output - HTML context updates for model/instructions Styling: - globals: overlay-dim, scrollbar styles, prism gradient utilities - theme vars: secondary-accent, prism-gradient; tailwind color addition Misc: - fix PDF scale calc to use container height - EPUB theme reader area fills height - time estimation update cadence stab - audio util passes format to combine endpoint --- src/app/api/audio/convert/chapter/route.ts | 134 ++++ src/app/api/audio/convert/chapters/route.ts | 98 +++ src/app/api/audio/convert/route.ts | 307 +++++--- src/app/epub/[id]/page.tsx | 148 +++- src/app/globals.css | 114 +++ src/app/html/[id]/page.tsx | 103 ++- src/app/layout.tsx | 18 +- src/app/page.tsx | 28 +- src/app/pdf/[id]/page.tsx | 140 ++-- src/components/AudiobookExportModal.tsx | 733 ++++++++++++++++++ src/components/ConfirmDialog.tsx | 6 +- src/components/DocumentSettings.tsx | 151 ++-- src/components/DocumentUploader.tsx | 73 +- src/components/EPUBViewer.tsx | 8 +- src/components/HTMLViewer.tsx | 30 +- src/components/Header.tsx | 29 + src/components/HomeContent.tsx | 24 + src/components/PDFViewer.tsx | 37 +- src/components/ProgressCard.tsx | 96 +++ src/components/ProgressPopup.tsx | 84 +- src/components/SettingsModal.tsx | 46 +- src/components/ZoomControl.tsx | 39 + src/components/doclist/CreateFolderDialog.tsx | 2 +- src/components/doclist/DocumentFolder.tsx | 27 +- src/components/doclist/DocumentList.tsx | 25 +- src/components/doclist/DocumentListItem.tsx | 38 +- src/components/doclist/SortControls.tsx | 4 +- src/components/icons/Icons.tsx | 176 ++++- src/components/player/Navigator.tsx | 8 +- src/components/player/SpeedControl.tsx | 5 +- src/components/player/TTSPlayer.tsx | 16 +- src/components/player/VoicesControl.tsx | 7 +- src/contexts/EPUBContext.tsx | 294 ++++++- src/contexts/HTMLContext.tsx | 11 +- src/contexts/PDFContext.tsx | 302 +++++++- src/hooks/epub/useEPUBTheme.ts | 1 + src/hooks/useTimeEstimation.ts | 4 +- src/utils/audio.ts | 7 +- tailwind.config.ts | 1 + 39 files changed, 2767 insertions(+), 607 deletions(-) create mode 100644 src/app/api/audio/convert/chapter/route.ts create mode 100644 src/app/api/audio/convert/chapters/route.ts create mode 100644 src/components/AudiobookExportModal.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/HomeContent.tsx create mode 100644 src/components/ProgressCard.tsx create mode 100644 src/components/ZoomControl.tsx diff --git a/src/app/api/audio/convert/chapter/route.ts b/src/app/api/audio/convert/chapter/route.ts new file mode 100644 index 0000000..5a43d47 --- /dev/null +++ b/src/app/api/audio/convert/chapter/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createReadStream, existsSync } from 'fs'; +import { readFile, unlink } from 'fs/promises'; +import { join } from 'path'; + +export async function GET(request: NextRequest) { + try { + const bookId = request.nextUrl.searchParams.get('bookId'); + const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); + + if (!bookId || !chapterIndexStr) { + return NextResponse.json( + { error: 'Missing bookId or chapterIndex parameter' }, + { status: 400 } + ); + } + + const chapterIndex = parseInt(chapterIndexStr); + if (isNaN(chapterIndex)) { + return NextResponse.json( + { error: 'Invalid chapterIndex parameter' }, + { status: 400 } + ); + } + + const docstoreDir = join(process.cwd(), 'docstore'); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + + // Read metadata to get format + const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); + if (!existsSync(metadataPath)) { + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); + } + + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')); + const format = metadata.format || 'm4b'; + const chapterPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); + + if (!existsSync(chapterPath)) { + return NextResponse.json({ error: 'Chapter file not found' }, { status: 404 }); + } + + // Stream the chapter file + const stream = createReadStream(chapterPath); + + const readableWebStream = new ReadableStream({ + start(controller) { + stream.on('data', (chunk) => { + controller.enqueue(chunk); + }); + stream.on('end', () => { + controller.close(); + }); + stream.on('error', (err) => { + controller.error(err); + }); + }, + cancel() { + stream.destroy(); + } + }); + + const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; + const sanitizedTitle = metadata.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + + return new NextResponse(readableWebStream, { + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); + + } catch (error) { + console.error('Error downloading chapter:', error); + return NextResponse.json( + { error: 'Failed to download chapter' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const bookId = request.nextUrl.searchParams.get('bookId'); + const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); + + if (!bookId || !chapterIndexStr) { + return NextResponse.json( + { error: 'Missing bookId or chapterIndex parameter' }, + { status: 400 } + ); + } + + const chapterIndex = parseInt(chapterIndexStr, 10); + if (isNaN(chapterIndex)) { + return NextResponse.json( + { error: 'Invalid chapterIndex parameter' }, + { status: 400 } + ); + } + + const docstoreDir = join(process.cwd(), 'docstore'); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + + // Read metadata to get format (if present) + const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); + + // Delete the chapter audio file (try both formats just in case) + const chapterPathM4b = join(intermediateDir, `${chapterIndex}-chapter.m4b`); + const chapterPathMp3 = join(intermediateDir, `${chapterIndex}-chapter.mp3`); + if (existsSync(chapterPathM4b)) await unlink(chapterPathM4b).catch(() => {}); + if (existsSync(chapterPathMp3)) await unlink(chapterPathMp3).catch(() => {}); + + // Delete metadata if present + if (existsSync(metadataPath)) { + await unlink(metadataPath).catch(() => {}); + } + + // Invalidate any combined "complete" files + const completeM4b = join(intermediateDir, `complete.m4b`); + const completeMp3 = join(intermediateDir, `complete.mp3`); + if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {}); + if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {}); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting chapter:', error); + return NextResponse.json( + { error: 'Failed to delete chapter' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/audio/convert/chapters/route.ts b/src/app/api/audio/convert/chapters/route.ts new file mode 100644 index 0000000..76efd83 --- /dev/null +++ b/src/app/api/audio/convert/chapters/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readdir, readFile, rm } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +export async function GET(request: NextRequest) { + try { + const bookId = request.nextUrl.searchParams.get('bookId'); + if (!bookId) { + return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); + } + + const docstoreDir = join(process.cwd(), 'docstore'); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + + if (!existsSync(intermediateDir)) { + return NextResponse.json({ chapters: [], exists: false }); + } + + // Read all chapter metadata + const files = await readdir(intermediateDir); + const metaFiles = files.filter(f => f.endsWith('.meta.json')); + const chapters: Array<{ + index: number; + title: string; + duration?: number; + status: 'completed' | 'error'; + bookId: string; + format?: 'mp3' | 'm4b'; + }> = []; + + for (const metaFile of metaFiles) { + try { + const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); + chapters.push({ + index: meta.index, + title: meta.title, + duration: meta.duration, + status: 'completed', + bookId, + format: meta.format || 'm4b' + }); + } catch (error) { + console.error(`Error reading metadata file ${metaFile}:`, error); + } + } + + // Sort chapters by index + chapters.sort((a, b) => a.index - b.index); + + // Check if complete audiobook exists (either format) + const format = chapters[0]?.format || 'm4b'; + const completePath = join(intermediateDir, `complete.${format}`); + const hasComplete = existsSync(completePath); + + return NextResponse.json({ + chapters, + exists: true, + hasComplete, + bookId + }); + + } catch (error) { + console.error('Error fetching chapters:', error); + return NextResponse.json( + { error: 'Failed to fetch chapters' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const bookId = request.nextUrl.searchParams.get('bookId'); + if (!bookId) { + return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); + } + + const docstoreDir = join(process.cwd(), 'docstore'); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); + + // If directory doesn't exist, consider it already reset + if (!existsSync(intermediateDir)) { + return NextResponse.json({ success: true, existed: false }); + } + + // Recursively delete the entire audiobook directory + await rm(intermediateDir, { recursive: true, force: true }); + + return NextResponse.json({ success: true, existed: true }); + } catch (error) { + console.error('Error resetting audiobook:', error); + return NextResponse.json( + { error: 'Failed to reset audiobook' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts index e57dcd2..4c4a3f3 100644 --- a/src/app/api/audio/convert/route.ts +++ b/src/app/api/audio/convert/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { writeFile, readFile, mkdir, unlink, rmdir, readdir } from 'fs/promises'; +import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -9,9 +9,11 @@ interface ConversionRequest { chapterTitle: string; buffer: number[]; bookId?: string; + format?: 'mp3' | 'm4b'; + chapterIndex?: number; } -async function getAudioDuration(filePath: string): Promise { +async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const ffprobe = spawn('ffprobe', [ '-i', filePath, @@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise { ]); let output = ''; + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffprobe.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + const cleanup = () => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + ffprobe.stdout.on('data', (data) => { output += data.toString(); }); ffprobe.on('close', (code) => { + if (finished) return; + cleanup(); if (code === 0) { const duration = parseFloat(output.trim()); resolve(duration); @@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise { }); ffprobe.on('error', (err) => { + if (finished) return; + cleanup(); reject(err); }); }); } -async function runFFmpeg(args: string[]): Promise { +async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', args); + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffmpeg.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + ffmpeg.stderr.on('data', (data) => { console.error(`ffmpeg stderr: ${data}`); }); ffmpeg.on('close', (code) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); if (code === 0) { resolve(); } else { @@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise { }); ffmpeg.on('error', (err) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); reject(err); }); }); @@ -66,51 +122,84 @@ export async function POST(request: NextRequest) { try { // Parse the request body const data: ConversionRequest = await request.json(); + const format = data.format || 'm4b'; - // Create temp directory if it doesn't exist - const tempDir = join(process.cwd(), 'temp'); - if (!existsSync(tempDir)) { - await mkdir(tempDir); + // Use docstore directory + const docstoreDir = join(process.cwd(), 'docstore'); + if (!existsSync(docstoreDir)) { + await mkdir(docstoreDir); } // Generate or use existing book ID const bookId = data.bookId || randomUUID(); - const intermediateDir = join(tempDir, `${bookId}-intermediate`); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); // Create intermediate directory if (!existsSync(intermediateDir)) { await mkdir(intermediateDir); } - // Count existing files to determine chapter index - const files = await readdir(intermediateDir); - const wavFiles = files.filter(f => f.endsWith('.wav')); - const chapterIndex = wavFiles.length; + // Use provided chapter index or find the next available index robustly (handles gaps) + let chapterIndex: number; + if (data.chapterIndex !== undefined) { + chapterIndex = data.chapterIndex; + } else { + const files = await readdir(intermediateDir); + const indices = files + .map(f => f.match(/^(\d+)-chapter\.(m4b|mp3)$/)) + .filter((m): m is RegExpMatchArray => Boolean(m)) + .map(m => parseInt(m[1], 10)) + .sort((a, b) => a - b); + // Find smallest non-negative integer not present + let next = 0; + for (const idx of indices) { + if (idx === next) { + next++; + } else if (idx > next) { + break; + } + } + chapterIndex = next; + } - // Write input file - const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`); - const outputPath = join(intermediateDir, `${chapterIndex}.wav`); + // Write input file (MP3 from TTS) + const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); + const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`); const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); - // Convert to WAV from raw aac with consistent format - await runFFmpeg([ - '-i', inputPath, - '-f', 'wav', - '-c:a', 'copy', - '-preset', 'ultrafast', - '-threads', '0', - outputPath - ]); + if (format === 'mp3') { + // For MP3, re-encode to ensure proper headers and consistent format + await runFFmpeg([ + '-y', // Overwrite output file without asking + '-i', inputPath, + '-c:a', 'libmp3lame', + '-b:a', '64k', + '-metadata', `title=${data.chapterTitle}`, + chapterOutputPath + ], request.signal); + } else { + // Convert MP3 to M4B container with proper encoding and metadata + await runFFmpeg([ + '-y', // Overwrite output file without asking + '-i', inputPath, + '-c:a', 'aac', + '-b:a', '64k', + '-metadata', `title=${data.chapterTitle}`, + '-f', 'mp4', + chapterOutputPath + ], request.signal); + } // Get the duration and save metadata - const duration = await getAudioDuration(outputPath); + const duration = await getAudioDuration(chapterOutputPath, request.signal); await writeFile(metadataPath, JSON.stringify({ title: data.chapterTitle, duration, - index: chapterIndex + index: chapterIndex, + format })); // Clean up input file @@ -123,9 +212,15 @@ export async function POST(request: NextRequest) { }); } catch (error) { + if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { + return NextResponse.json( + { error: 'cancelled' }, + { status: 499 } + ); + } console.error('Error processing audio chapter:', error); return NextResponse.json( - { error: 'Failed to process audio chapter' }, + { error: 'Failed to process audio chapter' }, { status: 500 } ); } @@ -134,15 +229,13 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { const bookId = request.nextUrl.searchParams.get('bookId'); + const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null; if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - const tempDir = join(process.cwd(), 'temp'); - const intermediateDir = join(tempDir, `${bookId}-intermediate`); - const outputPath = join(tempDir, `${bookId}.m4b`); - const metadataPath = join(tempDir, `${bookId}-metadata.txt`); - const listPath = join(tempDir, `${bookId}-list.txt`); + const docstoreDir = join(process.cwd(), 'docstore'); + const intermediateDir = join(docstoreDir, `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); @@ -151,21 +244,39 @@ export async function GET(request: NextRequest) { // Read all chapter metadata const files = await readdir(intermediateDir); const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: { title: string; duration: number; index: number }[] = []; + const chapters: { title: string; duration: number; index: number; format: string }[] = []; for (const metaFile of metaFiles) { const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); chapters.push(meta); } + if (chapters.length === 0) { + return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); + } + // Sort chapters by index chapters.sort((a, b) => a.index - b.index); + // Determine output format from existing chapter metadata to avoid mismatches + const chapterFormat = (chapters[0]?.format === 'mp3' || chapters[0]?.format === 'm4b') ? chapters[0].format : 'm4b'; + if (requestedFormat && requestedFormat !== chapterFormat) { + console.warn(`Requested format ${requestedFormat} differs from chapter format ${chapterFormat}. Using ${chapterFormat}.`); + } + const format = chapterFormat; + const outputPath = join(intermediateDir, `complete.${format}`); + const metadataPath = join(intermediateDir, 'metadata.txt'); + const listPath = join(intermediateDir, 'list.txt'); - // Create chapter metadata file + // Check if combined file already exists + if (existsSync(outputPath)) { + // Stream the existing file + return streamFile(outputPath, format); + } + + // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; - // Calculate chapter timings based on actual durations chapters.forEach((chapter) => { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; @@ -185,72 +296,86 @@ export async function GET(request: NextRequest) { // Create list file for concat await writeFile( listPath, - chapters.map(c => `file '${join(intermediateDir, `${c.index}.wav`)}'`).join('\n') + chapters.map(c => `file '${join(intermediateDir, `${c.index}-chapter.${format}`)}'`).join('\n') ); - // Combine all files into a single M4B - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-i', metadataPath, - '-map_metadata', '1', - '-c:a', 'copy', // c:a is codec for audio and :a is stream specifier - //'-codec', 'wav', - //'-b:a', '192k', - //'-threads', '0', // Use maximum available threads - //'-movflags', '+faststart', - //'-preset', 'ultrafast', // Use fastest encoding preset - outputPath + if (format === 'mp3') { + // For MP3, re-encode to properly rebuild headers and duration metadata + // Using libmp3lame to ensure proper MP3 structure + await runFFmpeg([ + '-f', 'concat', + '-safe', '0', + '-i', listPath, + '-c:a', 'libmp3lame', + '-b:a', '64k', + outputPath + ], request.signal); + } else { + // Combine all files into a single M4B with chapter metadata + await runFFmpeg([ + '-f', 'concat', + '-safe', '0', + '-i', listPath, + '-i', metadataPath, + '-map_metadata', '1', + '-c:a', 'copy', + '-f', 'mp4', + outputPath + ], request.signal); + } + + // Clean up temporary files (but keep the chapters and complete file) + await Promise.all([ + unlink(metadataPath).catch(console.error), + unlink(listPath).catch(console.error) ]); // Stream the file back to the client - const stream = createReadStream(outputPath); - - // Clean up function - const cleanup = async () => { - try { - await Promise.all([ - ...chapters.map(c => unlink(join(intermediateDir, `${c.index}.wav`))), - ...chapters.map(c => unlink(join(intermediateDir, `${c.index}.meta.json`))), - unlink(metadataPath), - unlink(listPath), - unlink(outputPath), - rmdir(intermediateDir) - ]); - } catch (error) { - console.error('Cleanup error:', error); - } - }; - - // Clean up after streaming is complete - stream.on('end', cleanup); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, - }); - - return new NextResponse(readableWebStream, { - headers: { - 'Content-Type': 'audio/mp4', - }, - }); + return streamFile(outputPath, format); } catch (error) { + if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { + return NextResponse.json( + { error: 'cancelled' }, + { status: 499 } + ); + } console.error('Error creating M4B:', error); return NextResponse.json( - { error: 'Failed to create M4B file' }, + { error: 'Failed to create M4B file' }, { status: 500 } ); } +} + +// Helper function to stream file +function streamFile(filePath: string, format: string) { + const stream = createReadStream(filePath); + + const readableWebStream = new ReadableStream({ + start(controller) { + stream.on('data', (chunk) => { + controller.enqueue(chunk); + }); + stream.on('end', () => { + controller.close(); + }); + stream.on('error', (err) => { + controller.error(err); + }); + }, + cancel() { + stream.destroy(); + } + }); + + const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; + + return new NextResponse(readableWebStream, { + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="audiobook.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); } \ No newline at end of file diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index d3659a5..4995191 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -6,18 +6,26 @@ import { useCallback, useEffect, useState } from 'react'; import { useEPUB } from '@/contexts/EPUBContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { EPUBViewer } from '@/components/EPUBViewer'; -import { Button } from '@headlessui/react'; 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'; export default function EPUBPage() { const { id } = useParams(); - const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB(); + const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { stop } = useTTS(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('auto'); + const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width, 0 = max padding) + const [maxPadPx, setMaxPadPx] = useState(0); const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); @@ -43,6 +51,55 @@ export default function EPUBPage() { 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: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, + format: 'mp3' | 'm4b' + ) => { + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + }, [createEPUBAudioBook, id]); + + const handleRegenerateChapter = useCallback(async ( + chapterIndex: number, + bookId: string, + format: 'mp3' | 'm4b', + onProgress: (progress: number) => void, + signal: AbortSignal + ) => { + return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal); + }, [regenerateEPUBChapter]); + if (error) { return (
@@ -50,7 +107,7 @@ export default function EPUBPage() { clearCurrDoc()} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors" + 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" > @@ -63,39 +120,68 @@ export default function EPUBPage() { return ( <> -
-
-
- clearCurrDoc()} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" +
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" + > + + + + Documents + + } + title={isLoading ? 'Loading…' : (currDocName || '')} + right={ +
+ 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} + /> + + + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
- {isLoading ? ( -
- -
- ) : ( - - )} + + ); diff --git a/src/app/globals.css b/src/app/globals.css index 83f97ec..d2f3ff3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,7 +10,13 @@ html.light { --base: #f7fafc; --offbase: #e2e8f0; --accent: #ef4444; + --secondary-accent: #3b82f6; --muted: #718096; + --prism-gradient: linear-gradient(90deg, + #fecaca, + #f87171, + #ef4444 + ); } html.dark { @@ -19,7 +25,13 @@ html.dark { --base: #171717; --offbase: #343434; --accent: #f87171; + --secondary-accent: #60a5fa; --muted: #a3a3a3; + --prism-gradient: linear-gradient(90deg, + #fca5a5, + #fb7185, + #f87171 + ); } html.ocean { @@ -28,7 +40,13 @@ html.ocean { --base: #0f172a; --offbase: #1e293b; --accent: #38bdf8; + --secondary-accent: #22d3ee; --muted: #94a3b8; + --prism-gradient: linear-gradient(90deg, + #7dd3fc, + #38bdf8, + #0ea5e9 + ); } html.forest { @@ -37,7 +55,13 @@ html.forest { --base: #111a15; --offbase: #1a2820; --accent: #4ade80; + --secondary-accent: #22c55e; --muted: #7c8f85; + --prism-gradient: linear-gradient(90deg, + #86efac, + #4ade80, + #22c55e + ); } html.sunset { @@ -46,7 +70,13 @@ html.sunset { --base: #2c1810; --offbase: #3d1f14; --accent: #ff6b6b; + --secondary-accent: #f59e0b; --muted: #bc8f8f; + --prism-gradient: linear-gradient(90deg, + #fca5a5, + #fb7185, + #ff6b6b + ); } html.sea { @@ -55,7 +85,13 @@ html.sea { --base: #102c3d; --offbase: #1a3c52; --accent: #06b6d4; + --secondary-accent: #0ea5e9; --muted: #7ca7c4; + --prism-gradient: linear-gradient(90deg, + #67e8f9, + #22d3ee, + #06b6d4 + ); } html.mint { @@ -64,7 +100,13 @@ html.mint { --base: #132d27; --offbase: #1c3d35; --accent: #2dd4bf; + --secondary-accent: #10b981; --muted: #75a99c; + --prism-gradient: linear-gradient(90deg, + #99f6e4, + #5eead4, + #2dd4bf + ); } body { @@ -76,3 +118,75 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + +/* App shell utility (fullscreen, vertical layout) */ +.app-shell { + --header-height: 3.25rem; +} + +/* Themed overlay using foreground color without relying on /opacity classes */ +.overlay-dim { + background-color: color-mix(in srgb, var(--foreground), transparent 75%); +} + +/* Scrollbar styling for better fullscreen experience */ +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} +*::-webkit-scrollbar-track { + background: var(--base); +} +*::-webkit-scrollbar-thumb { + background: var(--offbase); + border-radius: 6px; + border: 2px solid var(--base); +} +*::-webkit-scrollbar-thumb:hover { + background: var(--muted); +} + +/* Subtle prism animated outline around list items (very light), matches the row's rounded-lg curve exactly */ +.prism-outline { + position: relative; + border: 1px solid transparent; + /* do not set border-radius here; let the element's rounded-lg from Tailwind define the curve */ + background: + linear-gradient(var(--card-fill, var(--offbase)), var(--card-fill, var(--offbase))) padding-box, + var(--prism-gradient) border-box; + background-clip: padding-box, border-box; + background-size: 300% 300%; + animation: prism-shift 8s linear infinite, prism-fade 3s ease-in-out infinite; +} + +@keyframes prism-shift { + 0% { background-position: 0% 50%; } + 100% { background-position: 300% 50%; } +} + +/* Gentle opacity pulse to simulate a "breath" without shadows spilling outside */ +@keyframes prism-fade { + 0% { opacity: 0.8; } + 50% { opacity: 1.0; } + 100% { opacity: 0.8; } +} + +/* Static prism gradient divider (no animation) */ +.prism-divider { + width: 100%; + height: 0.7px; /* use 2px then feather for a crisper center */ + position: relative; + border: 0; + background: none; + opacity: 0.95; +} +.prism-divider::before { + content: ''; + position: absolute; + inset: 0; + background: var(--prism-gradient); + /* Feather edges so they appear thinner */ + mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%); + -webkit-mask: linear-gradient(90deg, transparent 0%, black 8%, black 92%, transparent 100%); + border-radius: 999px; /* subtle rounding to reinforce taper */ +} diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index a5100bc..8127fc0 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from 'react'; import { useHTML } from '@/contexts/HTMLContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { HTMLViewer } from '@/components/HTMLViewer'; -import { Button } from '@headlessui/react'; 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'; export default function HTMLPage() { const { id } = useParams(); @@ -18,6 +20,9 @@ export default function HTMLPage() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('auto'); + const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) + const [maxPadPx, setMaxPadPx] = useState(0); const loadDocument = useCallback(async () => { if (!isLoading) return; @@ -41,6 +46,29 @@ export default function HTMLPage() { loadDocument(); }, [loadDocument]); + // 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`); + + // Adaptive minimum content width: allow some padding on narrow screens + const vw = window.innerWidth; + const desiredMin = 640; + 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); + }, []); + if (error) { return (
@@ -48,7 +76,7 @@ export default function HTMLPage() { {clearCurrDoc();}} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors" + 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" > @@ -61,39 +89,52 @@ export default function HTMLPage() { return ( <> -
-
-
- {clearCurrDoc();}} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" - > - - - - Documents - - + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
- {isLoading ? ( -
- -
- ) : ( - - )} + ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 708d1f9..164a6f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import { Providers } from "@/app/providers"; import { Metadata } from "next"; import { Footer } from "@/components/Footer"; import { Toaster } from 'react-hot-toast'; -import { Analytics } from "@vercel/analytics/next" +import { Analytics } from "@vercel/analytics/next"; export const metadata: Metadata = { title: "OpenReader WebUI", @@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) { -
-
-
- {children} - +
+
+ {children} + +
+ {!isDev && ( +
+
- {!isDev &&
} -
+ )}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b74e42..286c7e5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,27 @@ -import { DocumentUploader } from '@/components/DocumentUploader'; -import { DocumentList } from '@/components/doclist/DocumentList'; +import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; +// Home page redesigned for fullscreen layout: hero + document area. + export default function Home() { return ( -
+
-

OpenReader WebUI

-

A bring your own text-to-speech api web interface for reading documents with high quality voices

-
- - -
+
+
+

OpenReader WebUI

+

+ Bring your own text-to-speech API. + Read & listen to PDF, EPUB & HTML documents with high quality voices. +

+
+
+
+
+ +
); } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index fe3d6fd..532003c 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -7,9 +7,12 @@ import Link from 'next/link'; import { useCallback, useEffect, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; -import { Button } from '@headlessui/react'; import { DocumentSettings } from '@/components/DocumentSettings'; -import { SettingsIcon } from '@/components/icons/Icons'; +import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons'; +import { Header } from '@/components/Header'; +import { ZoomControl } from '@/components/ZoomControl'; +import { AudiobookExportModal } from '@/components/AudiobookExportModal'; +import TTSPlayer from '@/components/player/TTSPlayer'; // Dynamic import for client-side rendering only const PDFViewer = dynamic( @@ -22,12 +25,14 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const { id } = useParams(); - const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF(); + const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { stop } = useTTS(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); + const [containerHeight, setContainerHeight] = useState('auto'); const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc @@ -51,9 +56,44 @@ export default function PDFViewerPage() { loadDocument(); }, [loadDocument]); + // 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(); + window.addEventListener('resize', compute); + return () => window.removeEventListener('resize', compute); + }, []); + const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); + const handleGenerateAudiobook = useCallback(async ( + onProgress: (progress: number) => void, + signal: AbortSignal, + onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, + format: 'mp3' | 'm4b' + ) => { + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); + }, [createPDFAudioBook, id]); + + const handleRegenerateChapter = useCallback(async ( + chapterIndex: number, + bookId: string, + format: 'mp3' | 'm4b', + onProgress: (progress: number) => void, + signal: AbortSignal + ) => { + return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal); + }, [regeneratePDFChapter]); + if (error) { return (
@@ -61,7 +101,7 @@ export default function PDFViewerPage() { {clearCurrDoc();}} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors" + 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" > @@ -74,56 +114,60 @@ export default function PDFViewerPage() { return ( <> -
-
+
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" + > + + + + Documents + + } + title={isLoading ? 'Loading…' : (currDocName || '')} + right={
- {clearCurrDoc();}} - className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" + + - {zoomLevel}% - -
- + + +
-

- {isLoading ? 'Loading...' : currDocName} -

-
+ } + /> +
+ {isLoading ? ( +
+ +
+ ) : ( + + )}
- {isLoading ? ( -
- -
- ) : ( - - )} + + ); diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx new file mode 100644 index 0000000..a2b6e3e --- /dev/null +++ b/src/components/AudiobookExportModal.tsx @@ -0,0 +1,733 @@ +import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; +import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; +import { useTimeEstimation } from '@/hooks/useTimeEstimation'; +import { ProgressPopup } from '@/components/ProgressPopup'; +import { ProgressCard } from '@/components/ProgressCard'; +import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; +import { LoadingSpinner } from '@/components/Spinner'; +import { useConfig } from '@/contexts/ConfigContext'; + +interface AudiobookChapter { + index: number; + title: string; + duration?: number; + status: 'pending' | 'generating' | 'completed' | 'error'; + bookId?: string; + format?: 'mp3' | 'm4b'; +} + +interface AudiobookExportModalProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + documentType: 'epub' | 'pdf'; + documentId: string; + onGenerateAudiobook: ( + onProgress: (progress: number) => void, + signal: AbortSignal, + onChapterComplete: (chapter: AudiobookChapter) => void, + format: 'mp3' | 'm4b' + ) => Promise; // Returns bookId + onRegenerateChapter?: ( + chapterIndex: number, + bookId: string, + format: 'mp3' | 'm4b', + onProgress: (progress: number) => void, + signal: AbortSignal + ) => Promise; +} + +export function AudiobookExportModal({ + isOpen, + setIsOpen, + documentType, + documentId, + onGenerateAudiobook, + onRegenerateChapter +}: AudiobookExportModalProps) { + const { isLoading, isDBReady } = useConfig(); + const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); + const [isGenerating, setIsGenerating] = useState(false); + const [chapters, setChapters] = useState([]); + const [bookId, setBookId] = useState(null); + const [isCombining, setIsCombining] = useState(false); + const [isLoadingExisting, setIsLoadingExisting] = useState(false); + const [isRefreshingChapters, setIsRefreshingChapters] = useState(false); + const [currentChapter, setCurrentChapter] = useState(''); + const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b'); + const [regeneratingChapter, setRegeneratingChapter] = useState(null); + const [regenerationProgress, setRegenerationProgress] = useState(0); + const abortControllerRef = useRef(null); + const [pendingDeleteChapter, setPendingDeleteChapter] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const fetchExistingChapters = useCallback(async (soft: boolean = false) => { + if (soft) { + setIsRefreshingChapters(true); + } else { + setIsLoadingExisting(true); + } + try { + const response = await fetch(`/api/audio/convert/chapters?bookId=${documentId}`); + if (response.ok) { + const data = await response.json(); + if (data.exists && data.chapters.length > 0) { + setChapters(data.chapters); + setBookId(data.bookId); + // Set format from existing chapters - this ensures the format matches what was actually generated + if (data.chapters[0]?.format) { + const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b'; + setFormat(detectedFormat); + } + // If we have a complete audiobook, we're done + if (data.hasComplete) { + setProgress(100); + } + } else { + // If nothing exists, clear chapters/bookId to reflect current state + setChapters([]); + setBookId(null); + } + } + } catch (error) { + console.error('Error fetching existing chapters:', error); + } finally { + if (soft) { + setIsRefreshingChapters(false); + } else { + setIsLoadingExisting(false); + } + } + }, [documentId, setProgress]); + + // Fetch existing chapters when modal opens + useEffect(() => { + if (isOpen && documentId && !isGenerating) { + fetchExistingChapters(); + } + }, [isOpen, documentId, isGenerating, fetchExistingChapters]); + + const handleChapterComplete = useCallback((chapter: AudiobookChapter) => { + setChapters(prev => { + const existing = prev.find(c => c.index === chapter.index); + if (existing) { + return prev.map(c => c.index === chapter.index ? chapter : c); + } + return [...prev, chapter].sort((a, b) => a.index - b.index); + }); + setCurrentChapter(chapter.title); + }, []); + + const handleStartGeneration = useCallback(async () => { + setIsGenerating(true); + setProgress(0); + setCurrentChapter(''); + // Don't clear chapters if resuming + if (!bookId) { + setChapters([]); + setBookId(null); + } + abortControllerRef.current = new AbortController(); + + try { + const generatedBookId = await onGenerateAudiobook( + (progress) => setProgress(progress), + abortControllerRef.current.signal, + handleChapterComplete, + format + ); + setBookId(generatedBookId); + } catch (error) { + console.error('Error generating audiobook:', error); + if (error instanceof Error && error.message.includes('cancelled')) { + // Graceful cancellation - chapters are saved + console.log('Audiobook generation cancelled gracefully'); + } else { + // Show error to user for actual errors + setErrorMessage('Failed to generate audiobook. Please try again.'); + } + } finally { + setIsGenerating(false); + setProgress(0); + abortControllerRef.current = null; + // Refresh chapters to show what was completed (soft refresh list only) + if (bookId || documentId) { + await fetchExistingChapters(true); + } + } + }, [onGenerateAudiobook, handleChapterComplete, setProgress, bookId, format, documentId, fetchExistingChapters]); + + const handleCancel = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + + // Cancel in-flight conversion if the page is being hidden or the component unmounts + // (e.g., user navigates away from the document to the home screen). + useEffect(() => { + const onPageHide = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + window.addEventListener('pagehide', onPageHide); + window.addEventListener('beforeunload', onPageHide); + return () => { + window.removeEventListener('pagehide', onPageHide); + window.removeEventListener('beforeunload', onPageHide); + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + const handleRegenerateChapter = useCallback(async (chapter: AudiobookChapter) => { + if (!onRegenerateChapter || !bookId) return; + + setRegeneratingChapter(chapter.index); + setRegenerationProgress(0); + setCurrentChapter(`Regenerating: ${chapter.title}`); + abortControllerRef.current = new AbortController(); + + try { + // Update chapter status to generating + setChapters(prev => { + const exists = prev.some(c => c.index === chapter.index); + if (exists) { + return prev.map(c => + c.index === chapter.index + ? { ...c, status: 'generating' as const } + : c + ); + } + // If it's a missing placeholder, add it as generating + return [...prev, { ...chapter, status: 'generating' as const }].sort((a, b) => a.index - b.index); + }); + + const regeneratedChapter = await onRegenerateChapter( + chapter.index, + bookId, + format, + (progress) => { + setRegenerationProgress(progress); + setProgress(progress); + }, + abortControllerRef.current.signal + ); + + // Update chapter with new data + setChapters(prev => prev.map(c => + c.index === chapter.index + ? regeneratedChapter + : c + )); + + } catch (error) { + console.error('Error regenerating chapter:', error); + if (error instanceof Error && error.message.includes('cancelled')) { + console.log('Chapter regeneration cancelled'); + } else { + setErrorMessage('Failed to regenerate chapter. Please try again.'); + // Mark as error + setChapters(prev => prev.map(c => + c.index === chapter.index + ? { ...c, status: 'error' as const } + : c + )); + } + } finally { + setRegeneratingChapter(null); + setRegenerationProgress(0); + setCurrentChapter(''); + setProgress(0); + abortControllerRef.current = null; + // Refresh chapters to get updated data (soft refresh list only) + await fetchExistingChapters(true); + } + }, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters]); + + const performDeleteChapter = useCallback(async () => { + if (!bookId || !pendingDeleteChapter) return; + try { + const response = await fetch(`/api/audio/convert/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, { + method: 'DELETE' + }); + if (!response.ok) { + throw new Error('Delete failed'); + } + setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index)); + await fetchExistingChapters(true); + } catch (error) { + console.error('Error deleting chapter:', error); + setErrorMessage('Failed to delete chapter. Please try again.'); + } finally { + setPendingDeleteChapter(null); + } + }, [bookId, pendingDeleteChapter, fetchExistingChapters]); + + const performResetAll = useCallback(async () => { + const targetBookId = bookId || documentId; + if (!targetBookId) return; + try { + const resp = await fetch(`/api/audio/convert/chapters?bookId=${targetBookId}`, { method: 'DELETE' }); + if (!resp.ok) { + throw new Error('Reset failed'); + } + setChapters([]); + setBookId(null); + setProgress(0); + } catch (error) { + console.error('Error resetting audiobook chapters:', error); + setErrorMessage('Failed to reset chapters. Please try again.'); + } finally { + setShowResetConfirm(false); + await fetchExistingChapters(true); + } + }, [bookId, documentId, setProgress, fetchExistingChapters]); + + const handleDownloadChapter = useCallback(async (chapter: AudiobookChapter) => { + if (!chapter.bookId) return; + + try { + const response = await fetch(`/api/audio/convert/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`); + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + // Use the chapter's stored format directly - it knows what it actually is + const ext = chapter.format || 'm4b'; + a.download = `${chapter.title}.${ext}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Error downloading chapter:', error); + setErrorMessage('Failed to download chapter. Please try again.'); + } + }, []); + + const handleDownloadComplete = useCallback(async () => { + if (!bookId) return; + + setIsCombining(true); + try { + const response = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`); + if (!response.ok) throw new Error('Download failed'); + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No response body'); + + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; + const blob = new Blob(chunks as BlobPart[], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audiobook.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('Error downloading complete audiobook:', error); + setErrorMessage('Failed to download audiobook. Please try again.'); + } finally { + setIsCombining(false); + } + }, [bookId, format]); + + + const formatDuration = (seconds?: number) => { + if (!seconds) return '--:--'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Compute display list including gaps before the highest existing index + const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1; + const displayChapters: AudiobookChapter[] = + maxIndex >= 0 + ? Array.from({ length: maxIndex + 1 }, (_, i) => { + const existing = chapters.find(c => c.index === i); + if (existing) return existing; + return { + index: i, + title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`, + status: 'pending', + bookId: bookId || undefined, + format + }; + }) + : []; + + // Determine if we should show the Resume button + const hasIncompleteOrMissing = displayChapters.some(c => c.status !== 'completed'); + const showResumeButton = !isGenerating && chapters.length > 0 && (progress < 100 || hasIncompleteOrMissing); + + // Do not render until storage/config is initialized + if (isLoading || !isDBReady) { + return null; + } + + return ( + <> + setIsOpen(true)} + currentChapter={currentChapter} + totalChapters={documentType === 'epub' ? undefined : undefined} + completedChapters={chapters.filter(c => c.status === 'completed').length} + /> + + + setIsOpen(false)}> + +
+ + +
+
+ + + {isLoadingExisting ? ( +
+ +
+ ) : ( + <> +
+
+

Export Audiobook

+ {!isGenerating && ( +
+ {chapters.length === 0 && ( + setFormat(newFormat)} + disabled={chapters.length > 0} + > +
+ + {format.toUpperCase()} + + + + + + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${ + active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + M4B + + )} + + + `relative cursor-pointer select-none py-2 pl-3 pr-4 ${ + active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + > + {({ selected }) => ( + + MP3 + + )} + + + +
+
+ )} + {chapters.length === 0 && ( + + )} + {showResumeButton && ( + <> + + + + )} +
+ )} +
+ {/* Progress Info */} + {isGenerating && ( + c.status === 'completed').length} + cancelText="Cancel" + /> + )} + + {chapters.length > 0 && ( + <> +
+
+

Chapters

+ {isRefreshingChapters && } +
+ {displayChapters.map((chapter) => ( +
+
+ {chapter.status === 'completed' ? ( + + ) : onRegenerateChapter ? ( + + ) : ( + + )} +
+

+ {chapter.title} +

+

+ {chapter.status !== 'completed' && Missing • }Duration: {formatDuration(chapter.duration)} +

+
+
+
+ {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( + + + + + + + {onRegenerateChapter && !isGenerating && ( + + {({ active, disabled }) => ( + + )} + + )} + {chapter.status === 'completed' && ( + <> + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + )} + + + + )} +
+
+ ))} +
+ + {bookId && !isGenerating && ( +
+ +
+ )} + + )} + + {chapters.length === 0 && !isGenerating && !isLoadingExisting && ( +
+

+ Click "Start Generation" to begin creating your audiobook. +
+ Individual chapters will appear here as they are generated. +

+
+ )} +
+ +
+ +
+ + )} +
+
+
+
+
+
+ {/* Confirm delete chapter */} + setPendingDeleteChapter(null)} + onConfirm={performDeleteChapter} + title="Delete Chapter" + message={pendingDeleteChapter ? `Delete "${pendingDeleteChapter.title}"? This will remove the audio and metadata for this chapter.` : ''} + confirmText="Delete" + cancelText="Cancel" + isDangerous + /> + {/* Confirm reset all */} + setShowResetConfirm(false)} + onConfirm={performResetAll} + title="Reset Audiobook" + message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone." + confirmText="Reset" + cancelText="Cancel" + isDangerous + /> + {/* Error dialog replacing alerts */} + setErrorMessage(null)} + onConfirm={() => setErrorMessage(null)} + title="Operation Failed" + message={errorMessage || ''} + confirmText="Close" + cancelText="" + isDangerous={false} + /> + + ); +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index cd828d5..b8c5090 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -46,7 +46,7 @@ export function ConfirmDialog({ leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -88,8 +88,8 @@ export function ConfirmDialog({ font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] ${isDangerous - ? 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 hover:text-white' - : 'bg-accent text-white hover:bg-accent/90 focus-visible:ring-accent hover:text-background' + ? 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent' + : 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent' }`} onClick={onConfirm} > diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 02b6870..41dc385 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -1,13 +1,13 @@ 'use client'; -import { Fragment, useState, useRef, useCallback, useEffect } from 'react'; +import { Fragment, useState, useCallback, useEffect } from 'react'; import { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react'; import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { useEPUB } from '@/contexts/EPUBContext'; import { usePDF } from '@/contexts/PDFContext'; -import { useTimeEstimation } from '@/hooks/useTimeEstimation'; -import { ProgressPopup } from '@/components/ProgressPopup'; +import { AudiobookExportModal } from '@/components/AudiobookExportModal'; +import { useParams } from 'next/navigation'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -17,11 +17,6 @@ const viewTypes = [ { id: 'scroll', name: 'Continuous Scroll' }, ]; -const audioFormats = [ - { id: 'mp3', name: 'MP3' }, - { id: 'm4b', name: 'M4B' }, -]; - export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { isOpen: boolean, setIsOpen: (isOpen: boolean) => void, @@ -38,18 +33,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { rightMargin, updateConfigKey } = useConfig(); - const { createFullAudioBook, isAudioCombining } = useEPUB(); - const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF(); - const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); - const [isGenerating, setIsGenerating] = useState(false); - const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3'); + const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); + const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); + const { id } = useParams(); const [localMargins, setLocalMargins] = useState({ header: headerMargin, footer: footerMargin, left: leftMargin, right: rightMargin }); - const abortControllerRef = useRef(null); + const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0]; // Sync local margins with global state @@ -79,61 +72,42 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { } }; - const handleStartGeneration = useCallback(async () => { - setIsGenerating(true); - setProgress(0); - abortControllerRef.current = new AbortController(); - - try { - const audioBuffer = epub ? await createFullAudioBook( - (progress) => setProgress(progress), - abortControllerRef.current.signal, - audioFormat - ) : await createPDFAudioBook( - (progress) => setProgress(progress), - abortControllerRef.current.signal, - audioFormat - ); - - // Create and trigger download - const mimeType = audioFormat === 'mp3' ? 'audio/mp3' : 'audio/mp4'; - const blob = new Blob([audioBuffer], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `audiobook.${audioFormat}`; - document.body.appendChild(a); - a.click(); - - // Clean up - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 100); - } catch (error) { - console.error('Error generating audiobook:', error); - } finally { - setIsGenerating(false); - setProgress(0); - abortControllerRef.current = null; + const handleGenerateAudiobook = useCallback(async ( + onProgress: (progress: number) => void, + signal: AbortSignal, + onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, + format: 'mp3' | 'm4b' + ) => { + if (epub) { + return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format); + } else { + return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format); } - }, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]); + }, [epub, createEPUBAudioBook, createPDFAudioBook, id]); - const handleCancel = () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); + const handleRegenerateChapter = useCallback(async ( + chapterIndex: number, + bookId: string, + format: 'mp3' | 'm4b', + onProgress: (progress: number) => void, + signal: AbortSignal + ) => { + if (epub) { + return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal); + } else { + return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal); } - }; + }, [epub, regenerateEPUBChapter, regeneratePDFChapter]); return ( <> - @@ -147,7 +121,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -162,44 +136,17 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { leaveTo="opacity-0 scale-95" > - {isDev &&
-
- - setAudioFormat(format as 'mp3' | 'm4b')}> -
- - {audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'} - - - - {audioFormats.map((format) => ( - - `relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}` - } - > - {format.name} - - ))} - -
-
-
+ {isDev && !html &&
+
}
diff --git a/src/components/DocumentUploader.tsx b/src/components/DocumentUploader.tsx index 2fe91c7..0f4d58a 100644 --- a/src/components/DocumentUploader.tsx +++ b/src/components/DocumentUploader.tsx @@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N interface DocumentUploaderProps { className?: string; + variant?: 'default' | 'compact'; } -export function DocumentUploader({ className = '' }: DocumentUploaderProps) { +export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) { const { addPDFDocument: addPDF, addEPUBDocument: addEPUB, @@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) { disabled: isUploading || isConverting }); + const containerBase = `w-full border-2 border-dashed rounded-lg ${isDragActive ? 'border-accent bg-base' : 'border-muted'} transform transition-transform duration-200 ease-in-out ${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base hover:scale-[1.008]'} ${className}`; + const paddingClass = variant === 'compact' ? 'py-1.5 px-2' : 'py-5 px-3'; + return (
-
- - - {isUploading ? ( -

- Uploading file... -

- ) : isConverting ? ( -

- Converting DOCX to PDF... -

- ) : ( - <> -

- {isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'} -

-

- {isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'} -

- {error &&

{error}

} - - )} -
+ {variant === 'compact' ? ( +
+ + {isUploading ? ( +

Uploading…

+ ) : isConverting ? ( +

Converting DOCX…

+ ) : ( +
+

+ {isDragActive ? 'Drop files here' : 'Drop files or click'} +

+ {error &&

{error}

} +
+ )} +
+ ) : ( +
+ + {isUploading ? ( +

Uploading file...

+ ) : isConverting ? ( +

Converting DOCX to PDF...

+ ) : ( + <> +

+ {isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'} +

+

+ {isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'} +

+ {error &&

{error}

} + + )} +
+ )}
); } diff --git a/src/components/EPUBViewer.tsx b/src/components/EPUBViewer.tsx index 110cd4d..63568be 100644 --- a/src/components/EPUBViewer.tsx +++ b/src/components/EPUBViewer.tsx @@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; -import TTSPlayer from '@/components/player/TTSPlayer'; import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme'; import { useEPUBResize } from '@/hooks/epub/useEPUBResize'; @@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) { } return ( -
-
- -
-
+
+
} key={'epub-reader'} diff --git a/src/components/HTMLViewer.tsx b/src/components/HTMLViewer.tsx index 958e1f0..a667f27 100644 --- a/src/components/HTMLViewer.tsx +++ b/src/components/HTMLViewer.tsx @@ -4,7 +4,6 @@ import { useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useHTML } from '@/contexts/HTMLContext'; -import TTSPlayer from '@/components/player/TTSPlayer'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; interface HTMLViewerProps { @@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) { } // Check if the file is a txt file - const isTxtFile = currDocName?.toLowerCase().endsWith('.txt'); + const isTxtFile = currDocName?.toLowerCase().endsWith('.txt'); - return ( -
-
- -
-
-
- {isTxtFile ? ( - currDocData - ) : ( - - {currDocData} - - )} + return ( +
+
+
+ {isTxtFile ? ( + currDocData + ) : ( + + {currDocData} + + )} +
-
); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..b262df0 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ReactNode } from "react"; + +export function Header({ + left, + title, + right, +}: { + left?: ReactNode; + title?: ReactNode; + right?: ReactNode; +}) { + return ( +
+
+
+ {left} + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )} +
+
{right}
+
+
+ ); +} diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx new file mode 100644 index 0000000..686e528 --- /dev/null +++ b/src/components/HomeContent.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { DocumentUploader } from '@/components/DocumentUploader'; +import { DocumentList } from '@/components/doclist/DocumentList'; +import { useDocuments } from '@/contexts/DocumentContext'; + +export function HomeContent() { + const { pdfDocs, epubDocs, htmlDocs } = useDocuments(); + const totalDocs = (pdfDocs?.length || 0) + (epubDocs?.length || 0) + (htmlDocs?.length || 0); + + if (totalDocs === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index dda8291..6f66e5a 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -7,7 +7,6 @@ import 'react-pdf/dist/Page/TextLayer.css'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { usePDF } from '@/contexts/PDFContext'; -import TTSPlayer from '@/components/player/TTSPlayer'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; @@ -48,25 +47,23 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { currDocPage, } = usePDF(); - // Add static styles once during component initialization - const styleElement = document.createElement('style'); - styleElement.textContent = ` - .react-pdf__Page__textContent span { - cursor: pointer; - transition: background-color 0.2s ease; - } - .react-pdf__Page__textContent span:hover { - background-color: rgba(255, 255, 0, 0.2) !important; - } - `; - document.head.appendChild(styleElement); - - // Cleanup styles when component unmounts + // Add static styles once during component mount useEffect(() => { + const styleElement = document.createElement('style'); + styleElement.textContent = ` + .react-pdf__Page__textContent span { + cursor: pointer; + transition: background-color 0.2s ease; + } + .react-pdf__Page__textContent span:hover { + background-color: rgba(255, 255, 0, 0.2) !important; + } + `; + document.head.appendChild(styleElement); return () => { styleElement.remove(); }; - }, [styleElement]); + }, []); useEffect(() => { /* @@ -135,7 +132,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { // Modify scale calculation to be more efficient const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type - const containerHeight = window.innerHeight - 100; + const containerHeight = (containerRef.current?.clientHeight ?? window.innerHeight); const targetWidth = viewType === 'dual' ? (containerWidth - margin) / 2 // divide by 2 for dual pages : containerWidth - margin; @@ -165,7 +162,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { }, [calculateScale]); return ( -
+
} noData={} @@ -240,10 +237,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { )}
-
); } \ No newline at end of file diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx new file mode 100644 index 0000000..4468540 --- /dev/null +++ b/src/components/ProgressCard.tsx @@ -0,0 +1,96 @@ +import { LoadingSpinner } from './Spinner'; + +interface ProgressCardProps { + progress: number; + estimatedTimeRemaining?: string; + onCancel: (e?: React.MouseEvent) => void; + isProcessing?: boolean; + operationType?: 'sync' | 'load' | 'audiobook'; + cancelText?: string; + currentChapter?: string; + completedChapters?: number; + statusMessage?: string; +} + +export function ProgressCard({ + progress, + estimatedTimeRemaining, + onCancel, + isProcessing = false, + operationType, + cancelText = 'Cancel', + currentChapter, + completedChapters, + statusMessage +}: ProgressCardProps) { + const getOperationLabel = () => { + if (operationType === 'sync') return 'Saving to Server'; + if (operationType === 'load') return 'Loading from Server'; + if (operationType === 'audiobook') return 'Generating Audiobook'; + return null; + }; + + const operationLabel = getOperationLabel(); + + return ( +
+ {/* Header with operation type and cancel button */} +
+
+ {operationLabel && ( +
+ {operationLabel} +
+ )} + {statusMessage && ( +
+ {statusMessage} +
+ )} + {currentChapter && ( +
+ {currentChapter} +
+ )} +
+ +
+ + {/* Progress bar */} +
+
+
+ + {/* Stats row */} +
+ {completedChapters !== undefined && ( + <> + {completedChapters} chapters + + + )} + {Math.round(progress)}% + {estimatedTimeRemaining && ( + <> + + {estimatedTimeRemaining} + + )} +
+
+ ); +} diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index 65ded31..ab1ae7d 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -1,6 +1,6 @@ import { Fragment } from 'react'; import { Transition } from '@headlessui/react'; -import { LoadingSpinner } from './Spinner'; +import { ProgressCard } from './ProgressCard'; interface ProgressPopupProps { isOpen: boolean; @@ -9,11 +9,27 @@ interface ProgressPopupProps { onCancel: () => void; isProcessing: boolean; statusMessage?: string; - operationType?: 'sync' | 'load'; + operationType?: 'sync' | 'load' | 'audiobook'; cancelText?: string; + onClick?: () => void; + currentChapter?: string; + totalChapters?: number; + completedChapters?: number; } -export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCancel, isProcessing, statusMessage, operationType, cancelText = 'Cancel' }: ProgressPopupProps) { +export function ProgressPopup({ + isOpen, + progress, + estimatedTimeRemaining, + onCancel, + isProcessing, + statusMessage, + operationType, + cancelText = 'Cancel', + onClick, + currentChapter, + completedChapters +}: ProgressPopupProps) { return ( -
-
-
-
-
-
-
-
-
- {operationType && ( - - {operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'} - - )} - {statusMessage && {statusMessage}} -
- {Math.round(progress)}% complete - {estimatedTimeRemaining &&
- - {` ~${estimatedTimeRemaining}`} -
} -
-
- -
-
+
+
+
+ { + e?.stopPropagation(); + onCancel(); + }} + isProcessing={isProcessing} + operationType={operationType} + cancelText={cancelText} + currentChapter={currentChapter} + completedChapters={completedChapters} + statusMessage={statusMessage} + />
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index f58a50f..bc32181 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -267,11 +267,11 @@ export function SettingsModal() { <> @@ -285,7 +285,7 @@ export function SettingsModal() { leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -316,8 +316,8 @@ export function SettingsModal() { `w-full rounded-lg py-1 text-sm font-medium ring-accent/60 ring-offset-2 ring-offset-base ${selected - ? 'bg-accent text-white shadow' - : 'text-foreground hover:bg-accent/[0.12] hover:text-accent' + ? 'bg-accent text-background shadow' + : 'text-foreground hover:text-accent' }` } > @@ -329,8 +329,8 @@ export function SettingsModal() { ))} - -
+ +
p.id === localTTSProvider) || ttsProviders[0]} @@ -395,7 +395,7 @@ export function SettingsModal() {
-
+
-
+
{modelValue === 'gpt-4o-mini-tts' && ( -
+