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
This commit is contained in:
parent
e7ce1a34ce
commit
42665884d7
39 changed files with 2767 additions and 607 deletions
134
src/app/api/audio/convert/chapter/route.ts
Normal file
134
src/app/api/audio/convert/chapter/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/app/api/audio/convert/chapters/route.ts
Normal file
98
src/app/api/audio/convert/chapters/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { spawn } from 'child_process';
|
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 { existsSync, createReadStream } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -9,9 +9,11 @@ interface ConversionRequest {
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
buffer: number[];
|
buffer: number[];
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
|
format?: 'mp3' | 'm4b';
|
||||||
|
chapterIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudioDuration(filePath: string): Promise<number> {
|
async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ffprobe = spawn('ffprobe', [
|
const ffprobe = spawn('ffprobe', [
|
||||||
'-i', filePath,
|
'-i', filePath,
|
||||||
|
|
@ -21,11 +23,38 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let output = '';
|
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) => {
|
ffprobe.stdout.on('data', (data) => {
|
||||||
output += data.toString();
|
output += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
ffprobe.on('close', (code) => {
|
ffprobe.on('close', (code) => {
|
||||||
|
if (finished) return;
|
||||||
|
cleanup();
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
const duration = parseFloat(output.trim());
|
const duration = parseFloat(output.trim());
|
||||||
resolve(duration);
|
resolve(duration);
|
||||||
|
|
@ -35,20 +64,44 @@ async function getAudioDuration(filePath: string): Promise<number> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ffprobe.on('error', (err) => {
|
ffprobe.on('error', (err) => {
|
||||||
|
if (finished) return;
|
||||||
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFFmpeg(args: string[]): Promise<void> {
|
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const ffmpeg = spawn('ffmpeg', args);
|
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) => {
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
console.error(`ffmpeg stderr: ${data}`);
|
console.error(`ffmpeg stderr: ${data}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('close', (code) => {
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -57,6 +110,9 @@ async function runFFmpeg(args: string[]): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpeg.on('error', (err) => {
|
ffmpeg.on('error', (err) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -66,51 +122,84 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Parse the request body
|
// Parse the request body
|
||||||
const data: ConversionRequest = await request.json();
|
const data: ConversionRequest = await request.json();
|
||||||
|
const format = data.format || 'm4b';
|
||||||
|
|
||||||
// Create temp directory if it doesn't exist
|
// Use docstore directory
|
||||||
const tempDir = join(process.cwd(), 'temp');
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
if (!existsSync(tempDir)) {
|
if (!existsSync(docstoreDir)) {
|
||||||
await mkdir(tempDir);
|
await mkdir(docstoreDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate or use existing book ID
|
// Generate or use existing book ID
|
||||||
const bookId = data.bookId || randomUUID();
|
const bookId = data.bookId || randomUUID();
|
||||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
|
|
||||||
// Create intermediate directory
|
// Create intermediate directory
|
||||||
if (!existsSync(intermediateDir)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
await mkdir(intermediateDir);
|
await mkdir(intermediateDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count existing files to determine chapter index
|
// Use provided chapter index or find the next available index robustly (handles gaps)
|
||||||
const files = await readdir(intermediateDir);
|
let chapterIndex: number;
|
||||||
const wavFiles = files.filter(f => f.endsWith('.wav'));
|
if (data.chapterIndex !== undefined) {
|
||||||
const chapterIndex = wavFiles.length;
|
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
|
// Write input file (MP3 from TTS)
|
||||||
const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`);
|
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
|
||||||
const outputPath = join(intermediateDir, `${chapterIndex}.wav`);
|
const chapterOutputPath = join(intermediateDir, `${chapterIndex}-chapter.${format}`);
|
||||||
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`);
|
||||||
|
|
||||||
// Write the chapter audio to a temp file
|
// Write the chapter audio to a temp file
|
||||||
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
|
||||||
|
|
||||||
// Convert to WAV from raw aac with consistent format
|
if (format === 'mp3') {
|
||||||
await runFFmpeg([
|
// For MP3, re-encode to ensure proper headers and consistent format
|
||||||
'-i', inputPath,
|
await runFFmpeg([
|
||||||
'-f', 'wav',
|
'-y', // Overwrite output file without asking
|
||||||
'-c:a', 'copy',
|
'-i', inputPath,
|
||||||
'-preset', 'ultrafast',
|
'-c:a', 'libmp3lame',
|
||||||
'-threads', '0',
|
'-b:a', '64k',
|
||||||
outputPath
|
'-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
|
// Get the duration and save metadata
|
||||||
const duration = await getAudioDuration(outputPath);
|
const duration = await getAudioDuration(chapterOutputPath, request.signal);
|
||||||
await writeFile(metadataPath, JSON.stringify({
|
await writeFile(metadataPath, JSON.stringify({
|
||||||
title: data.chapterTitle,
|
title: data.chapterTitle,
|
||||||
duration,
|
duration,
|
||||||
index: chapterIndex
|
index: chapterIndex,
|
||||||
|
format
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Clean up input file
|
// Clean up input file
|
||||||
|
|
@ -123,9 +212,15 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} 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);
|
console.error('Error processing audio chapter:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to process audio chapter' },
|
{ error: 'Failed to process audio chapter' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,15 +229,13 @@ export async function POST(request: NextRequest) {
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
const bookId = request.nextUrl.searchParams.get('bookId');
|
||||||
|
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = join(process.cwd(), 'temp');
|
const docstoreDir = join(process.cwd(), 'docstore');
|
||||||
const intermediateDir = join(tempDir, `${bookId}-intermediate`);
|
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
|
||||||
const outputPath = join(tempDir, `${bookId}.m4b`);
|
|
||||||
const metadataPath = join(tempDir, `${bookId}-metadata.txt`);
|
|
||||||
const listPath = join(tempDir, `${bookId}-list.txt`);
|
|
||||||
|
|
||||||
if (!existsSync(intermediateDir)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
|
|
@ -151,21 +244,39 @@ export async function GET(request: NextRequest) {
|
||||||
// Read all chapter metadata
|
// Read all chapter metadata
|
||||||
const files = await readdir(intermediateDir);
|
const files = await readdir(intermediateDir);
|
||||||
const metaFiles = files.filter(f => f.endsWith('.meta.json'));
|
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) {
|
for (const metaFile of metaFiles) {
|
||||||
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8'));
|
||||||
chapters.push(meta);
|
chapters.push(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No chapters found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chapters by index
|
// Sort chapters by index
|
||||||
chapters.sort((a, b) => a.index - b.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[] = [];
|
const metadata: string[] = [];
|
||||||
let currentTime = 0;
|
let currentTime = 0;
|
||||||
|
|
||||||
// Calculate chapter timings based on actual durations
|
|
||||||
chapters.forEach((chapter) => {
|
chapters.forEach((chapter) => {
|
||||||
const startMs = Math.floor(currentTime * 1000);
|
const startMs = Math.floor(currentTime * 1000);
|
||||||
currentTime += chapter.duration;
|
currentTime += chapter.duration;
|
||||||
|
|
@ -185,72 +296,86 @@ export async function GET(request: NextRequest) {
|
||||||
// Create list file for concat
|
// Create list file for concat
|
||||||
await writeFile(
|
await writeFile(
|
||||||
listPath,
|
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
|
if (format === 'mp3') {
|
||||||
await runFFmpeg([
|
// For MP3, re-encode to properly rebuild headers and duration metadata
|
||||||
'-f', 'concat',
|
// Using libmp3lame to ensure proper MP3 structure
|
||||||
'-safe', '0',
|
await runFFmpeg([
|
||||||
'-i', listPath,
|
'-f', 'concat',
|
||||||
'-i', metadataPath,
|
'-safe', '0',
|
||||||
'-map_metadata', '1',
|
'-i', listPath,
|
||||||
'-c:a', 'copy', // c:a is codec for audio and :a is stream specifier
|
'-c:a', 'libmp3lame',
|
||||||
//'-codec', 'wav',
|
'-b:a', '64k',
|
||||||
//'-b:a', '192k',
|
outputPath
|
||||||
//'-threads', '0', // Use maximum available threads
|
], request.signal);
|
||||||
//'-movflags', '+faststart',
|
} else {
|
||||||
//'-preset', 'ultrafast', // Use fastest encoding preset
|
// Combine all files into a single M4B with chapter metadata
|
||||||
outputPath
|
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
|
// Stream the file back to the client
|
||||||
const stream = createReadStream(outputPath);
|
return streamFile(outputPath, format);
|
||||||
|
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'cancelled' },
|
||||||
|
{ status: 499 }
|
||||||
|
);
|
||||||
|
}
|
||||||
console.error('Error creating M4B:', error);
|
console.error('Error creating M4B:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to create M4B file' },
|
{ error: 'Failed to create M4B file' },
|
||||||
{ status: 500 }
|
{ 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -6,18 +6,26 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { EPUBViewer } from '@/components/EPUBViewer';
|
import { EPUBViewer } from '@/components/EPUBViewer';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
import { SettingsIcon } from '@/components/icons/Icons';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
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() {
|
export default function EPUBPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
|
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = 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 () => {
|
const loadDocument = useCallback(async () => {
|
||||||
console.log('Loading new epub (from page.tsx)');
|
console.log('Loading new epub (from page.tsx)');
|
||||||
|
|
@ -43,6 +51,55 @@ export default function EPUBPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument, isLoading]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -50,7 +107,7 @@ export default function EPUBPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => clearCurrDoc()}
|
onClick={() => 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"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -63,39 +120,68 @@ export default function EPUBPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
left={
|
||||||
<div className="flex items-center gap-1">
|
<Link
|
||||||
<Link
|
href="/"
|
||||||
href="/"
|
onClick={() => clearCurrDoc()}
|
||||||
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"
|
||||||
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]"
|
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}
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
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="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{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>
|
</div>
|
||||||
{isLoading ? (
|
<AudiobookExportModal
|
||||||
<div className="p-4">
|
isOpen={isAudiobookModalOpen}
|
||||||
<DocumentSkeleton />
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
</div>
|
documentType="epub"
|
||||||
) : (
|
documentId={id as string}
|
||||||
<EPUBViewer className="p-4" />
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
)}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
|
/>
|
||||||
|
<TTSPlayer />
|
||||||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ html.light {
|
||||||
--base: #f7fafc;
|
--base: #f7fafc;
|
||||||
--offbase: #e2e8f0;
|
--offbase: #e2e8f0;
|
||||||
--accent: #ef4444;
|
--accent: #ef4444;
|
||||||
|
--secondary-accent: #3b82f6;
|
||||||
--muted: #718096;
|
--muted: #718096;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fecaca,
|
||||||
|
#f87171,
|
||||||
|
#ef4444
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
|
|
@ -19,7 +25,13 @@ html.dark {
|
||||||
--base: #171717;
|
--base: #171717;
|
||||||
--offbase: #343434;
|
--offbase: #343434;
|
||||||
--accent: #f87171;
|
--accent: #f87171;
|
||||||
|
--secondary-accent: #60a5fa;
|
||||||
--muted: #a3a3a3;
|
--muted: #a3a3a3;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fca5a5,
|
||||||
|
#fb7185,
|
||||||
|
#f87171
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.ocean {
|
html.ocean {
|
||||||
|
|
@ -28,7 +40,13 @@ html.ocean {
|
||||||
--base: #0f172a;
|
--base: #0f172a;
|
||||||
--offbase: #1e293b;
|
--offbase: #1e293b;
|
||||||
--accent: #38bdf8;
|
--accent: #38bdf8;
|
||||||
|
--secondary-accent: #22d3ee;
|
||||||
--muted: #94a3b8;
|
--muted: #94a3b8;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#7dd3fc,
|
||||||
|
#38bdf8,
|
||||||
|
#0ea5e9
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.forest {
|
html.forest {
|
||||||
|
|
@ -37,7 +55,13 @@ html.forest {
|
||||||
--base: #111a15;
|
--base: #111a15;
|
||||||
--offbase: #1a2820;
|
--offbase: #1a2820;
|
||||||
--accent: #4ade80;
|
--accent: #4ade80;
|
||||||
|
--secondary-accent: #22c55e;
|
||||||
--muted: #7c8f85;
|
--muted: #7c8f85;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#86efac,
|
||||||
|
#4ade80,
|
||||||
|
#22c55e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.sunset {
|
html.sunset {
|
||||||
|
|
@ -46,7 +70,13 @@ html.sunset {
|
||||||
--base: #2c1810;
|
--base: #2c1810;
|
||||||
--offbase: #3d1f14;
|
--offbase: #3d1f14;
|
||||||
--accent: #ff6b6b;
|
--accent: #ff6b6b;
|
||||||
|
--secondary-accent: #f59e0b;
|
||||||
--muted: #bc8f8f;
|
--muted: #bc8f8f;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#fca5a5,
|
||||||
|
#fb7185,
|
||||||
|
#ff6b6b
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.sea {
|
html.sea {
|
||||||
|
|
@ -55,7 +85,13 @@ html.sea {
|
||||||
--base: #102c3d;
|
--base: #102c3d;
|
||||||
--offbase: #1a3c52;
|
--offbase: #1a3c52;
|
||||||
--accent: #06b6d4;
|
--accent: #06b6d4;
|
||||||
|
--secondary-accent: #0ea5e9;
|
||||||
--muted: #7ca7c4;
|
--muted: #7ca7c4;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#67e8f9,
|
||||||
|
#22d3ee,
|
||||||
|
#06b6d4
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.mint {
|
html.mint {
|
||||||
|
|
@ -64,7 +100,13 @@ html.mint {
|
||||||
--base: #132d27;
|
--base: #132d27;
|
||||||
--offbase: #1c3d35;
|
--offbase: #1c3d35;
|
||||||
--accent: #2dd4bf;
|
--accent: #2dd4bf;
|
||||||
|
--secondary-accent: #10b981;
|
||||||
--muted: #75a99c;
|
--muted: #75a99c;
|
||||||
|
--prism-gradient: linear-gradient(90deg,
|
||||||
|
#99f6e4,
|
||||||
|
#5eead4,
|
||||||
|
#2dd4bf
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -76,3 +118,75 @@ body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 */
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useHTML } from '@/contexts/HTMLContext';
|
import { useHTML } from '@/contexts/HTMLContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
import { SettingsIcon } from '@/components/icons/Icons';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
|
|
||||||
export default function HTMLPage() {
|
export default function HTMLPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -18,6 +20,9 @@ export default function HTMLPage() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||||
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return;
|
if (!isLoading) return;
|
||||||
|
|
@ -41,6 +46,29 @@ export default function HTMLPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -48,7 +76,7 @@ export default function HTMLPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => {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"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-2" 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -61,39 +89,52 @@ export default function HTMLPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
left={
|
||||||
<div className="flex items-center gap-1">
|
<Link
|
||||||
<Link
|
href="/"
|
||||||
href="/"
|
onClick={() => clearCurrDoc()}
|
||||||
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"
|
||||||
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]"
|
aria-label="Back to documents"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
Documents
|
Documents
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{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` }}>
|
||||||
|
<HTMLViewer className="h-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
<TTSPlayer />
|
||||||
<div className="p-4">
|
|
||||||
<DocumentSkeleton />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<HTMLViewer className="p-4" />
|
|
||||||
)}
|
|
||||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { Analytics } from "@vercel/analytics/next"
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenReader WebUI",
|
title: "OpenReader WebUI",
|
||||||
|
|
@ -56,14 +56,16 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<Providers>
|
<Providers>
|
||||||
<div className="min-h-screen bg-background p-4">
|
<div className="app-shell min-h-screen flex flex-col bg-background">
|
||||||
<div className="relative max-w-6xl mx-auto align-center">
|
<main className="flex-1 flex flex-col">
|
||||||
<div className="bg-base rounded-lg shadow-lg">
|
{children}
|
||||||
{children}
|
<Analytics />
|
||||||
<Analytics />
|
</main>
|
||||||
|
{!isDev && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
{!isDev && <Footer />}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,27 @@
|
||||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
import { HomeContent } from '@/components/HomeContent';
|
||||||
import { DocumentList } from '@/components/doclist/DocumentList';
|
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
|
|
||||||
|
// Home page redesigned for fullscreen layout: hero + document area.
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className='p-3.5 sm:p-5'>
|
<div className="flex flex-col h-full w-full">
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
|
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||||
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices</p>
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex flex-col items-center gap-5">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
||||||
<DocumentUploader className='max-w-xl' />
|
<p className="text-sm leading-relaxed max-w-prose text-foreground">
|
||||||
<DocumentList />
|
Bring your own text-to-speech API.
|
||||||
</div>
|
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex-1 px-4 pb-8 overflow-auto">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
||||||
|
<HomeContent />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ import Link from 'next/link';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
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
|
// Dynamic import for client-side rendering only
|
||||||
const PDFViewer = dynamic(
|
const PDFViewer = dynamic(
|
||||||
|
|
@ -22,12 +25,14 @@ const PDFViewer = dynamic(
|
||||||
|
|
||||||
export default function PDFViewerPage() {
|
export default function PDFViewerPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { setCurrentDocument, currDocName, clearCurrDoc } = usePDF();
|
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return; // Prevent calls when not loading new doc
|
if (!isLoading) return; // Prevent calls when not loading new doc
|
||||||
|
|
@ -51,9 +56,44 @@ export default function PDFViewerPage() {
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [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 handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300));
|
||||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50));
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -61,7 +101,7 @@ export default function PDFViewerPage() {
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => {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"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|
@ -74,56 +114,60 @@ export default function PDFViewerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-2 pb-2 border-b border-offbase">
|
<Header
|
||||||
<div className="flex flex-wrap items-center justify-between">
|
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-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
||||||
href="/"
|
<button
|
||||||
onClick={() => {clearCurrDoc();}}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
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]"
|
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"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
<div className="bg-offbase px-2 py-0.5 rounded-full flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
|
|
||||||
aria-label="Zoom out"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs">{zoomLevel}%</span>
|
|
||||||
<Button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="text-xs transform transition-transform duration-200 ease-in-out hover:scale-[1.45] hover:font-semibold hover:text-accent"
|
|
||||||
aria-label="Zoom in"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
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="View Settings"
|
aria-label="Open settings"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
}
|
||||||
{isLoading ? 'Loading...' : currDocName}
|
/>
|
||||||
</h1>
|
<div className="overflow-hidden" style={{ height: containerHeight }}>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<DocumentSkeleton />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PDFViewer zoomLevel={zoomLevel} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
<AudiobookExportModal
|
||||||
<div className="p-4">
|
isOpen={isAudiobookModalOpen}
|
||||||
<DocumentSkeleton />
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
</div>
|
documentType="pdf"
|
||||||
) : (
|
documentId={id as string}
|
||||||
<PDFViewer zoomLevel={zoomLevel} />
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
)}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
|
/>
|
||||||
|
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||||
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
733
src/components/AudiobookExportModal.tsx
Normal file
733
src/components/AudiobookExportModal.tsx
Normal file
|
|
@ -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<string>; // Returns bookId
|
||||||
|
onRegenerateChapter?: (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal
|
||||||
|
) => Promise<AudiobookChapter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AudiobookChapter[]>([]);
|
||||||
|
const [bookId, setBookId] = useState<string | null>(null);
|
||||||
|
const [isCombining, setIsCombining] = useState(false);
|
||||||
|
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||||
|
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
||||||
|
const [currentChapter, setCurrentChapter] = useState<string>('');
|
||||||
|
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
||||||
|
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||||
|
const [regenerationProgress, setRegenerationProgress] = useState<number>(0);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<AudiobookChapter | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<ProgressPopup
|
||||||
|
isOpen={isGenerating && !isOpen}
|
||||||
|
progress={progress}
|
||||||
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isProcessing={isCombining}
|
||||||
|
cancelText="Cancel"
|
||||||
|
operationType="audiobook"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
currentChapter={currentChapter}
|
||||||
|
totalChapters={documentType === 'epub' ? undefined : undefined}
|
||||||
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
|
{isLoadingExisting ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center gap-3">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
||||||
|
{!isGenerating && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{chapters.length === 0 && (
|
||||||
|
<Listbox
|
||||||
|
value={format}
|
||||||
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
|
disabled={chapters.length > 0}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<ListboxButton className="relative cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[100px]">
|
||||||
|
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||||
|
<ListboxOption
|
||||||
|
value="m4b"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
|
||||||
|
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
M4B
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
<ListboxOption
|
||||||
|
value="mp3"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
|
||||||
|
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
MP3
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
)}
|
||||||
|
{chapters.length === 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||||
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
Start Generation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showResumeButton && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||||
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-red-500 px-4 py-2 text-sm
|
||||||
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
title="Delete all generated chapters/pages for this document"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Progress Info */}
|
||||||
|
{isGenerating && (
|
||||||
|
<ProgressCard
|
||||||
|
progress={progress}
|
||||||
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isProcessing={isCombining}
|
||||||
|
operationType="audiobook"
|
||||||
|
currentChapter={currentChapter}
|
||||||
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
cancelText="Cancel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chapters.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className={`space-y-2 max-h-96 overflow-y-auto ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
|
||||||
|
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{displayChapters.map((chapter) => (
|
||||||
|
<div
|
||||||
|
key={chapter.index}
|
||||||
|
className={`flex items-center justify-between p-2 sm:p-3 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
{chapter.status === 'completed' ? (
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-accent" />
|
||||||
|
) : onRegenerateChapter ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-accent/10 text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ClockIcon className="h-5 w-5 text-muted" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{chapter.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{chapter.status !== 'completed' && <span className="text-warning">Missing • </span>}Duration: {formatDuration(chapter.duration)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
<MenuButton
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
title="Chapter actions"
|
||||||
|
>
|
||||||
|
<DotsVerticalIcon className="h-5 w-5" />
|
||||||
|
</MenuButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<MenuItems className="absolute right-0 mt-2 w-44 origin-top-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
|
||||||
|
{onRegenerateChapter && !isGenerating && (
|
||||||
|
<MenuItem disabled={regeneratingChapter !== null}>
|
||||||
|
{({ active, disabled }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
title="Regenerate this chapter"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{regeneratingChapter === chapter.index ? `Regenerating (${Math.round(regenerationProgress)}%)` : 'Regenerate'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{chapter.status === 'completed' && (
|
||||||
|
<>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
<span>Download</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDeleteChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-accent/10 text-accent' : 'text-accent'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
title="Delete this chapter"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItems>
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookId && !isGenerating && (
|
||||||
|
<div className="pt-4 border-t border-offbase">
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadComplete}
|
||||||
|
disabled={isCombining}
|
||||||
|
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-4 py-3 text-sm
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||||
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-5 w-5" />
|
||||||
|
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Click "Start Generation" to begin creating your audiobook.
|
||||||
|
<br />
|
||||||
|
Individual chapters will appear here as they are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
||||||
|
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
{/* Confirm delete chapter */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDeleteChapter !== null}
|
||||||
|
onClose={() => 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 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showResetConfirm}
|
||||||
|
onClose={() => 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 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={errorMessage !== null}
|
||||||
|
onClose={() => setErrorMessage(null)}
|
||||||
|
onConfirm={() => setErrorMessage(null)}
|
||||||
|
title="Operation Failed"
|
||||||
|
message={errorMessage || ''}
|
||||||
|
confirmText="Close"
|
||||||
|
cancelText=""
|
||||||
|
isDangerous={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ export function ConfirmDialog({
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -88,8 +88,8 @@ export function ConfirmDialog({
|
||||||
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
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]
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
||||||
${isDangerous
|
${isDangerous
|
||||||
? 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 hover:text-white'
|
? 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent'
|
||||||
: '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'
|
||||||
}`}
|
}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
'use client';
|
'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 { Dialog, DialogPanel, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
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' },
|
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const audioFormats = [
|
|
||||||
{ id: 'mp3', name: 'MP3' },
|
|
||||||
{ id: 'm4b', name: 'M4B' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
|
|
@ -38,18 +33,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
rightMargin,
|
rightMargin,
|
||||||
updateConfigKey
|
updateConfigKey
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
const { createFullAudioBook, isAudioCombining } = useEPUB();
|
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||||
const { createFullAudioBook: createPDFAudioBook, isAudioCombining: isPDFAudioCombining } = usePDF();
|
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { id } = useParams();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [audioFormat, setAudioFormat] = useState<'mp3' | 'm4b'>('mp3');
|
|
||||||
const [localMargins, setLocalMargins] = useState({
|
const [localMargins, setLocalMargins] = useState({
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
footer: footerMargin,
|
footer: footerMargin,
|
||||||
left: leftMargin,
|
left: leftMargin,
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
||||||
|
|
||||||
// Sync local margins with global state
|
// Sync local margins with global state
|
||||||
|
|
@ -79,61 +72,42 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartGeneration = useCallback(async () => {
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
setIsGenerating(true);
|
onProgress: (progress: number) => void,
|
||||||
setProgress(0);
|
signal: AbortSignal,
|
||||||
abortControllerRef.current = new AbortController();
|
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
format: 'mp3' | 'm4b'
|
||||||
try {
|
) => {
|
||||||
const audioBuffer = epub ? await createFullAudioBook(
|
if (epub) {
|
||||||
(progress) => setProgress(progress),
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
abortControllerRef.current.signal,
|
} else {
|
||||||
audioFormat
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
) : 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;
|
|
||||||
}
|
}
|
||||||
}, [createFullAudioBook, createPDFAudioBook, epub, audioFormat, setProgress]);
|
}, [epub, createEPUBAudioBook, createPDFAudioBook, id]);
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleRegenerateChapter = useCallback(async (
|
||||||
if (abortControllerRef.current) {
|
chapterIndex: number,
|
||||||
abortControllerRef.current.abort();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProgressPopup
|
<AudiobookExportModal
|
||||||
isOpen={isGenerating}
|
isOpen={isAudiobookModalOpen}
|
||||||
progress={progress}
|
setIsOpen={setIsAudiobookModalOpen}
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
documentType={epub ? 'epub' : 'pdf'}
|
||||||
onCancel={handleCancel}
|
documentId={id as string}
|
||||||
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
|
onGenerateAudiobook={handleGenerateAudiobook}
|
||||||
cancelText="Cancel and download"
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
@ -147,7 +121,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -162,44 +136,17 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
{isDev && <div className="space-y-2">
|
{isDev && !html && <div className="space-y-2 mb-4">
|
||||||
<div className="flex flex-col space-y-2">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||||
disabled={isGenerating}
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
font-medium text-background hover:opacity-95 focus:outline-none
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
>
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
Export Audiobook
|
||||||
onClick={handleStartGeneration}
|
</Button>
|
||||||
>
|
|
||||||
Export to {audioFormat.toUpperCase()} (experimental)
|
|
||||||
</Button>
|
|
||||||
<Listbox value={audioFormat} onChange={(format) => setAudioFormat(format as 'mp3' | 'm4b')}>
|
|
||||||
<div className="relative flex self-end">
|
|
||||||
<ListboxButton
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="flex self-end justify-center items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
|
|
||||||
<span>{audioFormat === 'mp3' ? 'MP3' : 'M4B (Audiobook)'}</span>
|
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
|
||||||
</ListboxButton>
|
|
||||||
<ListboxOptions anchor='bottom end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
|
||||||
{audioFormats.map((format) => (
|
|
||||||
<ListboxOption
|
|
||||||
key={format.id}
|
|
||||||
value={format.id}
|
|
||||||
className={({ active, selected }) =>
|
|
||||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="text-xs sm:text-sm">{format.name}</span>
|
|
||||||
</ListboxOption>
|
|
||||||
))}
|
|
||||||
</ListboxOptions>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
|
||||||
|
|
||||||
interface DocumentUploaderProps {
|
interface DocumentUploaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: 'default' | 'compact';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
|
||||||
const {
|
const {
|
||||||
addPDFDocument: addPDF,
|
addPDFDocument: addPDF,
|
||||||
addEPUBDocument: addEPUB,
|
addEPUBDocument: addEPUB,
|
||||||
|
|
@ -87,41 +88,51 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
disabled: isUploading || isConverting
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`
|
className={`${containerBase} ${paddingClass}`}
|
||||||
w-full py-5 px-3 border-2 border-dashed rounded-lg
|
|
||||||
${isDragActive ? 'border-accent bg-base' : 'border-muted'}
|
|
||||||
transform trasition-transform duration-200 ease-in-out hover:scale-[1.008]
|
|
||||||
${(isUploading || isConverting) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-accent hover:bg-base'}
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
{variant === 'compact' ? (
|
||||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
<div className="flex items-center gap-2 text-left">
|
||||||
|
<UploadIcon className="w-5 h-5 text-muted" />
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
<p className="text-xs font-medium text-foreground">Uploading…</p>
|
||||||
Uploading file...
|
) : isConverting ? (
|
||||||
</p>
|
<p className="text-xs font-medium text-foreground">Converting DOCX…</p>
|
||||||
) : isConverting ? (
|
) : (
|
||||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
<div className="flex items-center gap-2">
|
||||||
Converting DOCX to PDF...
|
<p className="text-xs font-medium text-foreground">
|
||||||
</p>
|
{isDragActive ? 'Drop files here' : 'Drop files or click'}
|
||||||
) : (
|
</p>
|
||||||
<>
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
</div>
|
||||||
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
)}
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs sm:text-sm text-muted">
|
) : (
|
||||||
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
<div className="flex flex-col items-center justify-center text-center">
|
||||||
</p>
|
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
{isUploading ? (
|
||||||
</>
|
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
|
||||||
)}
|
) : isConverting ? (
|
||||||
</div>
|
<p className="text-sm sm:text-lg font-semibold text-foreground">Converting DOCX to PDF...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||||
|
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs sm:text-sm text-muted">
|
||||||
|
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||||
|
</p>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
|
|
||||||
|
|
@ -65,11 +64,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
||||||
<div className="z-10">
|
<div className="flex-1">
|
||||||
<TTSPlayer />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 -mt-16 pt-16">
|
|
||||||
<ReactReader
|
<ReactReader
|
||||||
loadingView={<DocumentSkeleton />}
|
loadingView={<DocumentSkeleton />}
|
||||||
key={'epub-reader'}
|
key={'epub-reader'}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useRef } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useHTML } from '@/contexts/HTMLContext';
|
import { useHTML } from '@/contexts/HTMLContext';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
|
|
||||||
interface HTMLViewerProps {
|
interface HTMLViewerProps {
|
||||||
|
|
@ -20,24 +19,21 @@ export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a txt file
|
// Check if the file is a txt file
|
||||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
|
||||||
<div className="z-10">
|
<div className="flex-1 overflow-auto">
|
||||||
<TTSPlayer />
|
<div className={`min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
||||||
</div>
|
{isTxtFile ? (
|
||||||
<div className="flex-1 overflow-auto">
|
currDocData
|
||||||
<div className={`min-w-full px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
|
) : (
|
||||||
{isTxtFile ? (
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
currDocData
|
{currDocData}
|
||||||
) : (
|
</ReactMarkdown>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
)}
|
||||||
{currDocData}
|
</div>
|
||||||
</ReactMarkdown>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/components/Header.tsx
Normal file
29
src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
left,
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
left?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base" data-app-header>
|
||||||
|
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{left}
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/HomeContent.tsx
Normal file
24
src/components/HomeContent.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<DocumentUploader className="py-12" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<DocumentList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ import 'react-pdf/dist/Page/TextLayer.css';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { usePDF } from '@/contexts/PDFContext';
|
import { usePDF } from '@/contexts/PDFContext';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
|
import { usePDFResize } from '@/hooks/pdf/usePDFResize';
|
||||||
|
|
||||||
|
|
@ -48,25 +47,23 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
currDocPage,
|
currDocPage,
|
||||||
} = usePDF();
|
} = usePDF();
|
||||||
|
|
||||||
// Add static styles once during component initialization
|
// Add static styles once during component mount
|
||||||
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
|
|
||||||
useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
styleElement.remove();
|
styleElement.remove();
|
||||||
};
|
};
|
||||||
}, [styleElement]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
/*
|
||||||
|
|
@ -135,7 +132,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
// Modify scale calculation to be more efficient
|
// Modify scale calculation to be more efficient
|
||||||
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
|
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
|
||||||
const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type
|
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'
|
const targetWidth = viewType === 'dual'
|
||||||
? (containerWidth - margin) / 2 // divide by 2 for dual pages
|
? (containerWidth - margin) / 2 // divide by 2 for dual pages
|
||||||
: containerWidth - margin;
|
: containerWidth - margin;
|
||||||
|
|
@ -165,7 +162,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
}, [calculateScale]);
|
}, [calculateScale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
||||||
<Document
|
<Document
|
||||||
loading={<DocumentSkeleton />}
|
loading={<DocumentSkeleton />}
|
||||||
noData={<DocumentSkeleton />}
|
noData={<DocumentSkeleton />}
|
||||||
|
|
@ -240,10 +237,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Document>
|
</Document>
|
||||||
<TTSPlayer
|
|
||||||
currentPage={currDocPage}
|
|
||||||
numPages={currDocPages}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
96
src/components/ProgressCard.tsx
Normal file
96
src/components/ProgressCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-offbase rounded-lg p-3 space-y-2">
|
||||||
|
{/* Header with operation type and cancel button */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
{operationLabel && (
|
||||||
|
<div className="text-accent font-semibold text-xs uppercase tracking-wide">
|
||||||
|
{operationLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusMessage && (
|
||||||
|
<div className="text-sm font-medium text-foreground truncate" title={statusMessage}>
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentChapter && (
|
||||||
|
<div className="text-sm font-medium text-foreground truncate" title={currentChapter}>
|
||||||
|
{currentChapter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
|
||||||
|
onClick={(e) => onCancel(e)}
|
||||||
|
>
|
||||||
|
{isProcessing && (
|
||||||
|
<span className="w-3 h-3">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{cancelText}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-background rounded-full overflow-hidden h-1.5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
{completedChapters !== undefined && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{completedChapters} chapters</span>
|
||||||
|
<span>•</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{Math.round(progress)}%</span>
|
||||||
|
{estimatedTimeRemaining && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{estimatedTimeRemaining}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { LoadingSpinner } from './Spinner';
|
import { ProgressCard } from './ProgressCard';
|
||||||
|
|
||||||
interface ProgressPopupProps {
|
interface ProgressPopupProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -9,11 +9,27 @@ interface ProgressPopupProps {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
operationType?: 'sync' | 'load';
|
operationType?: 'sync' | 'load' | 'audiobook';
|
||||||
cancelText?: string;
|
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 (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
|
|
@ -25,46 +41,28 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 -translate-y-4"
|
leaveTo="opacity-0 -translate-y-4"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none">
|
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
|
||||||
<div className="w-full max-w-sm mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
|
<div
|
||||||
<div className="space-y-2 truncate">
|
className={`pointer-events-auto shadow-xl ${
|
||||||
<div className="w-full bg-background rounded-lg overflow-hidden">
|
onClick ? 'cursor-pointer' : ''
|
||||||
<div
|
}`}
|
||||||
className="h-2 bg-accent transition-all duration-300 ease-in-out"
|
onClick={onClick}
|
||||||
style={{ width: `${progress}%` }}
|
>
|
||||||
/>
|
<ProgressCard
|
||||||
</div>
|
progress={progress}
|
||||||
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
|
estimatedTimeRemaining={estimatedTimeRemaining}
|
||||||
<div className="flex flex-col gap-1">
|
onCancel={(e) => {
|
||||||
{operationType && (
|
e?.stopPropagation();
|
||||||
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
|
onCancel();
|
||||||
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
|
}}
|
||||||
</span>
|
isProcessing={isProcessing}
|
||||||
)}
|
operationType={operationType}
|
||||||
{statusMessage && <span className="text-foreground font-medium">{statusMessage}</span>}
|
cancelText={cancelText}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
currentChapter={currentChapter}
|
||||||
<span>{Math.round(progress)}% complete</span>
|
completedChapters={completedChapters}
|
||||||
{estimatedTimeRemaining && <div>
|
statusMessage={statusMessage}
|
||||||
<span>•</span>
|
/>
|
||||||
<span>{` ~${estimatedTimeRemaining}`}</span>
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center gap-1 rounded-lg px-2.5 py-1 font-medium text-foreground hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{isProcessing && (
|
|
||||||
<span className="relative inline-block w-4 h-4">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{cancelText}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -267,11 +267,11 @@ export function SettingsModal() {
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-1 left-1 sm:top-3 sm:left-3"
|
className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 hover:animate-spin-slow" />
|
<SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
@ -285,7 +285,7 @@ export function SettingsModal() {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -316,8 +316,8 @@ export function SettingsModal() {
|
||||||
`w-full rounded-lg py-1 text-sm font-medium
|
`w-full rounded-lg py-1 text-sm font-medium
|
||||||
ring-accent/60 ring-offset-2 ring-offset-base
|
ring-accent/60 ring-offset-2 ring-offset-base
|
||||||
${selected
|
${selected
|
||||||
? 'bg-accent text-white shadow'
|
? 'bg-accent text-background shadow'
|
||||||
: 'text-foreground hover:bg-accent/[0.12] hover:text-accent'
|
: 'text-foreground hover:text-accent'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -329,8 +329,8 @@ export function SettingsModal() {
|
||||||
))}
|
))}
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels className="mt-2">
|
<TabPanels className="mt-2">
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-2.5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
|
||||||
<Listbox
|
<Listbox
|
||||||
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
|
||||||
|
|
@ -395,7 +395,7 @@ export function SettingsModal() {
|
||||||
</Listbox>
|
</Listbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
API Key
|
API Key
|
||||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||||
|
|
@ -411,7 +411,7 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Listbox
|
<Listbox
|
||||||
|
|
@ -485,7 +485,7 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modelValue === 'gpt-4o-mini-tts' && (
|
{modelValue === 'gpt-4o-mini-tts' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
<label className="block text-sm font-medium text-foreground">TTS Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={localTTSInstructions}
|
value={localTTSInstructions}
|
||||||
|
|
@ -497,7 +497,7 @@ export function SettingsModal() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
API Base URL
|
API Base URL
|
||||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||||
|
|
@ -514,7 +514,7 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
|
|
@ -534,8 +534,8 @@ export function SettingsModal() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-accent/90 focus:outline-none
|
font-medium text-background hover:bg-accent/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
|
|
@ -557,7 +557,7 @@ export function SettingsModal() {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="space-y-4 pb-3">
|
<TabPanel className="space-y-4 pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Theme</label>
|
<label className="block text-sm font-medium text-foreground">Theme</label>
|
||||||
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||||
|
|
@ -603,7 +603,7 @@ export function SettingsModal() {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-4">
|
||||||
{isDev && <div className="space-y-2">
|
{isDev && <div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Document Sync</label>
|
<label className="block text-sm font-medium text-foreground">Document Sync</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -631,23 +631,23 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-2 pb-3">
|
<div className="space-y-1 pb-3">
|
||||||
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
|
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowClearLocalConfirm(true)}
|
onClick={() => setShowClearLocalConfirm(true)}
|
||||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
font-medium text-background hover:bg-accent/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete local docs
|
Delete local docs
|
||||||
</Button>
|
</Button>
|
||||||
{isDev && <Button
|
{isDev && <Button
|
||||||
onClick={() => setShowClearServerConfirm(true)}
|
onClick={() => setShowClearServerConfirm(true)}
|
||||||
className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-white hover:bg-red-700 focus:outline-none
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete server docs
|
Delete server docs
|
||||||
|
|
|
||||||
39
src/components/ZoomControl.tsx
Normal file
39
src/components/ZoomControl.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export function ZoomControl({
|
||||||
|
value,
|
||||||
|
onIncrease,
|
||||||
|
onDecrease,
|
||||||
|
min = 50,
|
||||||
|
max = 300,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onIncrease: () => void;
|
||||||
|
onDecrease: () => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrease}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="text-xs tabular-nums w-12 text-center text-muted">{value}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrease}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="px-1 text-sm leading-none text-foreground hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.09]"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ export function CreateFolderDialog({
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/25 backdrop-blur-sm" />
|
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ interface DocumentFolderProps {
|
||||||
onDrop: (e: DragEvent, folderId: string) => void;
|
onDrop: (e: DragEvent, folderId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChevronIcon = ({ className = "w-5 h-5" }) => (
|
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
||||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -62,36 +62,33 @@ export function DocumentFolder({
|
||||||
if (!draggedDoc || draggedDoc.folderId) return;
|
if (!draggedDoc || draggedDoc.folderId) return;
|
||||||
onDrop(e, folder.id);
|
onDrop(e, folder.id);
|
||||||
}}
|
}}
|
||||||
className={`overflow-hidden rounded-lg transition-all bg-offbase shadow hover:shadow-md ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
className={`overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
|
||||||
>
|
>
|
||||||
<div className='flex flex-row justify-between p-2'>
|
<div className='flex flex-row justify-between p-0'>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className={`flex items-center justify-between ${isCollapsed ? 'mb-0' : 'mb-2'} transition-all duration-200`}>
|
<div className={`flex items-center justify-between px-2 py-1 bg-offbase rounded-t-md border-b border-offbase transition-all duration-200`}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h3 className="text-lg px-1 font-semibold">{folder.name}</h3>
|
<h3 className="text-sm px-1 font-semibold leading-tight">{folder.name}</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onToggleCollapse(folder.id)}
|
onClick={() => onToggleCollapse(folder.id)}
|
||||||
className="transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:font-semibold hover:text-accent"
|
className="ml-0.5 transform transition-transform duration-200 ease-in-out hover:scale-[1.08] hover:text-accent"
|
||||||
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
aria-label={isCollapsed ? "Expand folder" : "Collapse folder"}
|
||||||
>
|
>
|
||||||
<ChevronIcon
|
<ChevronIcon className={`transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`} />
|
||||||
className={`w-5 h-5 transform transition-transform duration-300 ease-in-out ${isCollapsed ? '-rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
className="p-1 text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-1 text-muted hover:text-accent hover:bg-base rounded-md transition-colors"
|
||||||
aria-label="Delete folder"
|
aria-label="Delete folder"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4"
|
<svg className="w-3.5 h-3.5"
|
||||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative bg-base px-1 py-1 rounded-b-md">
|
||||||
<div className="relative">
|
|
||||||
<Transition
|
<Transition
|
||||||
show={!isCollapsed}
|
show={!isCollapsed}
|
||||||
enter="transition-all duration-300 ease-out"
|
enter="transition-all duration-300 ease-out"
|
||||||
|
|
@ -101,7 +98,7 @@ export function DocumentFolder({
|
||||||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 origin-top">
|
<div className="space-y-1 origin-top">
|
||||||
{sortedDocuments.map(doc => (
|
{sortedDocuments.map(doc => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
key={`${doc.type}-${doc.id}`}
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
|
@ -125,7 +122,7 @@ export function DocumentFolder({
|
||||||
leaveFrom="max-h-[50px] opacity-100"
|
leaveFrom="max-h-[50px] opacity-100"
|
||||||
leaveTo="max-h-0 opacity-0"
|
leaveTo="max-h-0 opacity-0"
|
||||||
>
|
>
|
||||||
<p className="text-xs px-1 text-left text-muted">
|
<p className="text-[10px] px-1 text-left text-muted leading-tight">
|
||||||
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
{(calculateFolderSize(sortedDocuments) / 1024 / 1024).toFixed(2)} MB
|
||||||
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
{` • ${sortedDocuments.length} ${sortedDocuments.length === 1 ? 'file' : 'files'}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { DocumentFolder } from '@/components/doclist/DocumentFolder';
|
||||||
import { SortControls } from '@/components/doclist/SortControls';
|
import { SortControls } from '@/components/doclist/SortControls';
|
||||||
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
|
||||||
import { Button } from '@headlessui/react';
|
import { Button } from '@headlessui/react';
|
||||||
|
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||||
|
|
||||||
type DocumentToDelete = {
|
type DocumentToDelete = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -297,11 +298,18 @@ export function DocumentList() {
|
||||||
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
doc => !folders.some(folder => folder.documents.some(d => d.id === doc.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build compact summary (counts per type + total size)
|
||||||
|
const summaryParts: string[] = [];
|
||||||
|
if (pdfDocs.length) summaryParts.push(`${pdfDocs.length} PDF${pdfDocs.length === 1 ? '' : 's'}`);
|
||||||
|
if (epubDocs.length) summaryParts.push(`${epubDocs.length} EPUB${epubDocs.length === 1 ? '' : 's'}`);
|
||||||
|
if (htmlDocs.length) summaryParts.push(`${htmlDocs.length} HTML${htmlDocs.length === 1 ? '' : 's'}`);
|
||||||
|
const totalSizeMB = (allDocuments.reduce((acc, d) => acc + d.size, 0) / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="w-full mx-auto">
|
<div className="w-full mx-auto">
|
||||||
<div className="flex items-center justify-between mb-2 sm:mb-4">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Your Documents</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-foreground">Local Documents</h2>
|
||||||
<SortControls
|
<SortControls
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
|
@ -309,14 +317,21 @@ export function DocumentList() {
|
||||||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted mb-2" data-doc-summary>
|
||||||
|
{summaryParts.join(' • ')}{summaryParts.length ? ' • ' : ''}{totalSizeMB} MB total
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<DocumentUploader variant="compact" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{showHint && allDocuments.length > 1 && (
|
{showHint && allDocuments.length > 1 && (
|
||||||
<div className="flex items-center justify-between bg-background rounded-lg px-3 py-2 text-sm shadow hover:shadow-md transition-shadow">
|
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
|
||||||
<p className="text-sm">Drag files on top of each other to make folders</p>
|
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowHint(false)}
|
onClick={() => setShowHint(false)}
|
||||||
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||||
aria-label="Dismiss hint"
|
aria-label="Dismiss hint"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { DragEvent, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@headlessui/react';
|
import { Button } from '@headlessui/react';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
|
||||||
import { DocumentListDocument } from '@/types/documents';
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
interface DocumentListItemProps {
|
||||||
|
|
@ -50,41 +49,44 @@ export function DocumentListItem({
|
||||||
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
onDragOver={(e) => allowDropTarget && onDragOver?.(e, doc)}
|
||||||
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
onDragLeave={() => allowDropTarget && onDragLeave?.()}
|
||||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||||
|
aria-busy={loading}
|
||||||
className={`
|
className={`
|
||||||
w-full
|
w-full group
|
||||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent bg-primary/10' : ''}
|
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||||
bg-background rounded-lg p-2 shadow hover:shadow-md transition-shadow
|
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||||
relative
|
border border-offbase rounded-md p-1
|
||||||
|
transition-colors duration-150 relative
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{loading && (
|
<div className="flex items-center">
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80 rounded-lg">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center rounded-lg">
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
|
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
|
||||||
onClick={handleDocumentClick}
|
onClick={handleDocumentClick}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
|
{doc.type === 'pdf' ? (
|
||||||
|
<PDFIcon className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" />
|
||||||
|
) : doc.type === 'epub' ? (
|
||||||
|
<EPUBIcon className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<FileIcon className="w-6 h-6 sm:w-6 sm:h-6 text-muted" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
<div className="flex flex-col min-w-0 transform transition-transform duration-150 ease-in-out hover:scale-[1.009] w-full truncate">
|
||||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
<p className="text-[12px] sm:text-[13px] leading-tight text-foreground font-medium truncate group-hover:text-accent">{doc.name}</p>
|
||||||
<p className="text-xs sm:text-sm text-muted truncate">
|
<p className="text-[9px] sm:text-[10px] leading-tight text-muted truncate">
|
||||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => onDelete(doc)}
|
onClick={() => onDelete(doc)}
|
||||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
className="ml-1 p-1.5 text-muted hover:text-accent rounded-md hover:bg-offbase transition-colors"
|
||||||
aria-label="Delete document"
|
aria-label="Delete document"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ export function SortControls({
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
onClick={onSortDirectionChange}
|
onClick={onSortDirectionChange}
|
||||||
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap"
|
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
>
|
>
|
||||||
{directionLabel}
|
{directionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
export const PlayIcon = () => (
|
export const PlayIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width={props.width || 24}
|
||||||
height="32"
|
height={props.height || 24}
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="16"
|
cx="16"
|
||||||
|
|
@ -22,13 +24,15 @@ export const PlayIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PauseIcon = () => (
|
export const PauseIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width={props.width || 24}
|
||||||
height="32"
|
height={props.height || 24}
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="16"
|
cx="16"
|
||||||
|
|
@ -59,13 +63,15 @@ export const PauseIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SkipForwardIcon = () => (
|
export const SkipForwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width={props.width || 24}
|
||||||
height="24"
|
height={props.height || 24}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M6 4l10 8-10 8V4z"
|
d="M6 4l10 8-10 8V4z"
|
||||||
|
|
@ -84,13 +90,15 @@ export const SkipForwardIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SkipBackwardIcon = () => (
|
export const SkipBackwardIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width={props.width || 24}
|
||||||
height="24"
|
height={props.height || 24}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M18 4L8 12l10 8V4z"
|
d="M18 4L8 12l10 8V4z"
|
||||||
|
|
@ -262,3 +270,147 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function XCircleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="5" r="1.5" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" />
|
||||||
|
<circle cx="12" cy="19" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4zM3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707zM2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10zm9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5zm.754-4.246a.389.389 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.389.389 0 0 0-.029-.518z"></path>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A7.988 7.988 0 0 1 0 10zm8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={props.className}
|
||||||
|
width={props.width || "1.5em"}
|
||||||
|
height={props.height || "1.5em"}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M6 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M9 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M12 7V17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M15 8.42993V15.5699" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M18 9.85986V14.1499" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
|
@ -82,7 +82,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase/80"
|
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={handlePopoverOpen}
|
onClick={handlePopoverOpen}
|
||||||
>
|
>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
|
|
@ -105,7 +105,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
placeholder={currentPage.toString()}
|
placeholder={currentPage.toString()}
|
||||||
aria-label="Page number"
|
aria-label="Page number"
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-foreground/70 text-center">of {numPages || 1}</div>
|
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</>
|
</>
|
||||||
|
|
@ -117,7 +117,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||||
disabled={currentPage >= (numPages || 1)}
|
disabled={currentPage >= (numPages || 1)}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -54,7 +54,8 @@ export const SpeedControl = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
|
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||||
{/* Speed control */}
|
{/* Speed control */}
|
||||||
<SpeedControl
|
<SpeedControl
|
||||||
setSpeedAndRestart={setSpeedAndRestart}
|
setSpeedAndRestart={setSpeedAndRestart}
|
||||||
|
|
@ -51,29 +51,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
{/* Playback Controls */}
|
{/* Playback Controls */}
|
||||||
<Button
|
<Button
|
||||||
onClick={skipBackward}
|
onClick={skipBackward}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Skip backward"
|
aria-label="Skip backward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon />}
|
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={skipForward}
|
onClick={skipForward}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase/80 transition-colors duration-200 focus:outline-none disabled:opacity-50"
|
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||||
aria-label="Skip forward"
|
aria-label="Skip forward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon />}
|
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Voice control */}
|
{/* Voice control */}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
ListboxOption,
|
ListboxOption,
|
||||||
ListboxOptions,
|
ListboxOptions,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
|
||||||
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
|
|
@ -23,11 +23,12 @@ export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
|
<Listbox value={currentVoice} onChange={setVoiceAndRestart}>
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||||
|
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span>{currentVoice}</span>
|
<span>{currentVoice}</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
<ListboxOptions anchor='top end' className="absolute z-50 w-28 sm:w-32 max-h-64 overflow-auto rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||||
{availableVoices.map((voiceId) => (
|
{availableVoices.map((voiceId) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={voiceId}
|
key={voiceId}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { setLastDocumentLocation } from '@/utils/indexedDB';
|
||||||
import { SpineItem } from 'epubjs/types/section';
|
import { SpineItem } from 'epubjs/types/section';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { combineAudioChunks } from '@/utils/audio';
|
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
|
|
@ -31,7 +30,8 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
createFullAudioBook: (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, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
renditionRef: RefObject<Rendition | undefined>;
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
|
@ -59,12 +59,14 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
voice,
|
voice,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
const [isAudioCombining] = useState(false);
|
||||||
|
|
||||||
// Add new refs
|
// Add new refs
|
||||||
const bookRef = useRef<Book | null>(null);
|
const bookRef = useRef<Book | null>(null);
|
||||||
|
|
@ -197,21 +199,43 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: 'mp3' | 'm4b' = 'mp3'
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
if (!sections.length) throw new Error('No text content found in book');
|
if (!sections.length) throw new Error('No text content found in book');
|
||||||
|
|
||||||
// Calculate total length for accurate progress tracking
|
// Calculate total length for accurate progress tracking
|
||||||
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
const totalLength = sections.reduce((sum, section) => sum + section.text.trim().length, 0);
|
||||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let currentTime = 0;
|
let bookId: string = providedBookId || '';
|
||||||
|
|
||||||
// Get TOC for chapter titles
|
// Get TOC for chapter titles
|
||||||
const chapters = tocRef.current || [];
|
const chapters = tocRef.current || [];
|
||||||
console.log('Chapters:', chapters);
|
|
||||||
|
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||||
|
const existingIndices = new Set<number>();
|
||||||
|
if (bookId) {
|
||||||
|
try {
|
||||||
|
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
if (existingResponse.ok) {
|
||||||
|
const existingData = await existingResponse.json();
|
||||||
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
|
for (const ch of existingData.chapters) {
|
||||||
|
existingIndices.add(ch.index);
|
||||||
|
}
|
||||||
|
// Log smallest missing index for visibility
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing chapters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a map of section hrefs to their chapter titles
|
// Create a map of section hrefs to their chapter titles
|
||||||
const sectionTitleMap = new Map<string, string>();
|
const sectionTitleMap = new Map<string, string>();
|
||||||
|
|
@ -233,35 +257,52 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Section to chapter title mapping:', sectionTitleMap);
|
|
||||||
|
|
||||||
// Process each section
|
// Process each section
|
||||||
for (let i = 0; i < sections.length; i++) {
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
// Check for abort at the start of iteration
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
console.log('Generation cancelled by user');
|
||||||
return partialBuffer;
|
if (bookId) {
|
||||||
|
return bookId; // Return bookId with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const section = sections[i];
|
const section = sections[i];
|
||||||
const trimmedText = section.text.trim();
|
const trimmedText = section.text.trim();
|
||||||
if (!trimmedText) continue;
|
if (!trimmedText) continue;
|
||||||
|
|
||||||
|
// Skip chapters that already exist on disk (supports non-contiguous indices)
|
||||||
|
if (existingIndices.has(i)) {
|
||||||
|
processedLength += trimmedText.length;
|
||||||
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
// Check for abort before starting TTS request
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': apiKey,
|
'x-openai-key': apiKey,
|
||||||
'x-openai-base-url': baseUrl,
|
'x-openai-base-url': baseUrl,
|
||||||
'x-tts-provider': ttsProvider,
|
'x-tts-provider': ttsProvider,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text: trimmedText,
|
text: trimmedText,
|
||||||
voice: voice,
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
speed: voiceSpeed,
|
speed: voiceSpeed,
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3',
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
}),
|
}),
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
@ -289,47 +330,232 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
// If no chapter title found, use index-based naming
|
// If no chapter title found, use index-based naming
|
||||||
if (!chapterTitle) {
|
if (!chapterTitle) {
|
||||||
chapterTitle = `Unknown Section - ${i + 1}`;
|
chapterTitle = `Chapter ${i + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Processed audiobook chapter title:', chapterTitle);
|
// Check for abort before sending to server
|
||||||
audioChunks.push({
|
if (signal?.aborted) {
|
||||||
buffer: audioBuffer,
|
console.log('Generation cancelled before saving chapter');
|
||||||
title: chapterTitle,
|
if (bookId) {
|
||||||
startTime: currentTime
|
return bookId;
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex: i
|
||||||
|
}),
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add silence between sections
|
if (convertResponse.status === 499) {
|
||||||
const silenceBuffer = new ArrayBuffer(48000);
|
throw new Error('cancelled');
|
||||||
audioChunks.push({
|
}
|
||||||
buffer: silenceBuffer,
|
|
||||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
if (!convertResponse.ok) {
|
||||||
});
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
if (!bookId) {
|
||||||
|
bookId = returnedBookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about completed chapter
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTime += (audioBuffer.byteLength + 48000) / 48000;
|
|
||||||
processedLength += trimmedText.length;
|
processedLength += trimmedText.length;
|
||||||
onProgress((processedLength / totalLength) * 100);
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
console.log('TTS request aborted');
|
console.log('TTS request aborted, returning partial progress');
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
if (bookId) {
|
||||||
return partialBuffer;
|
return bookId; // Return with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
console.error('Error processing section:', error);
|
console.error('Error processing section:', error);
|
||||||
|
|
||||||
|
// Notify about error
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: i,
|
||||||
|
title: sectionTitleMap.get(section.href) || `Chapter ${i + 1}`,
|
||||||
|
status: 'error',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioChunks.length === 0) {
|
if (!bookId) {
|
||||||
throw new Error('No audio was generated from the book content');
|
throw new Error('No audio was generated from the book content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
return bookId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates a specific chapter of the audiobook
|
||||||
|
*/
|
||||||
|
const regenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
|
try {
|
||||||
|
const sections = await extractBookText();
|
||||||
|
if (chapterIndex >= sections.length) {
|
||||||
|
throw new Error('Invalid chapter index');
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = sections[chapterIndex];
|
||||||
|
const trimmedText = section.text.trim();
|
||||||
|
|
||||||
|
if (!trimmedText) {
|
||||||
|
throw new Error('No text content found in chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TOC for chapter title
|
||||||
|
const chapters = tocRef.current || [];
|
||||||
|
const sectionTitleMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const chapter of chapters) {
|
||||||
|
if (!chapter.href) continue;
|
||||||
|
const chapterBaseHref = chapter.href.split('#')[0];
|
||||||
|
const chapterTitle = chapter.label.trim();
|
||||||
|
|
||||||
|
for (const sect of sections) {
|
||||||
|
const sectionHref = sect.href;
|
||||||
|
const sectionBaseHref = sectionHref.split('#')[0];
|
||||||
|
|
||||||
|
if (sectionHref === chapter.href || sectionBaseHref === chapterBaseHref) {
|
||||||
|
sectionTitleMap.set(sectionHref, chapterTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterTitle = sectionTitleMap.get(section.href) || `Chapter ${chapterIndex + 1}`;
|
||||||
|
|
||||||
|
// Generate audio with retry logic
|
||||||
|
const audioBuffer = await withRetry(
|
||||||
|
async () => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: trimmedText,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ttsResponse.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await ttsResponse.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 2,
|
||||||
|
initialDelay: 5000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('Chapter regeneration cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (convertResponse.status === 499) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convertResponse.ok) {
|
||||||
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: returnedIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
throw new Error('Chapter regeneration cancelled');
|
||||||
|
}
|
||||||
|
console.error('Error regenerating chapter:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
bookRef.current = rendition.book;
|
||||||
|
|
@ -387,6 +613,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
renditionRef,
|
renditionRef,
|
||||||
tocRef,
|
tocRef,
|
||||||
|
|
@ -405,6 +632,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
handleLocationChanged,
|
handleLocationChanged,
|
||||||
setRendition,
|
setRendition,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||||
*/
|
*/
|
||||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
const { setText: setTTSText, stop } = useTTS();
|
const { setText: setTTSText, stop } = useTTS();
|
||||||
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider } = useConfig();
|
const { apiKey, baseUrl, voiceSpeed, voice, ttsProvider, ttsModel, ttsInstructions } = useConfig();
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<string>();
|
const [currDocData, setCurrDocData] = useState<string>();
|
||||||
|
|
@ -95,15 +95,18 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': apiKey,
|
'x-openai-key': apiKey,
|
||||||
'x-openai-base-url': baseUrl,
|
'x-openai-base-url': baseUrl,
|
||||||
'x-tts-provider': ttsProvider,
|
'x-tts-provider': ttsProvider,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text: currDocText,
|
text: currDocText,
|
||||||
voice: voice,
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
speed: voiceSpeed,
|
speed: voiceSpeed,
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
}),
|
}),
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
@ -147,7 +150,7 @@ export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
currDocData,
|
currDocData,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import {
|
||||||
} from '@/utils/pdf';
|
} from '@/utils/pdf';
|
||||||
|
|
||||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
import { withRetry } from '@/utils/audio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface defining all available methods and properties in the PDF context
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
|
@ -62,7 +62,8 @@ interface PDFContextType {
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
stopAndPlayFromIndex: (index: number) => void,
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
) => void;
|
) => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
createFullAudioBook: (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, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +98,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
voice,
|
voice,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
|
ttsModel,
|
||||||
|
ttsInstructions,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
|
|
@ -104,7 +107,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy>();
|
||||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
const [isAudioCombining] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles successful PDF document load
|
* Handles successful PDF document load
|
||||||
|
|
@ -189,14 +192,16 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
||||||
* @param {Function} onProgress - Callback for progress updates
|
* @param {Function} onProgress - Callback for progress updates
|
||||||
* @param {AbortSignal} signal - Optional signal for cancellation
|
* @param {AbortSignal} signal - Optional signal for cancellation
|
||||||
* @param {string} format - Optional format for the audiobook ('mp3' or 'm4b')
|
* @param {Function} onChapterComplete - Optional callback for when a chapter completes
|
||||||
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
|
* @returns {Promise<string>} The bookId for the generated audiobook
|
||||||
*/
|
*/
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
|
||||||
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: 'mp3' | 'm4b' = 'mp3'
|
||||||
): Promise<ArrayBuffer> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
throw new Error('No PDF document loaded');
|
throw new Error('No PDF document loaded');
|
||||||
|
|
@ -224,33 +229,72 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
throw new Error('No text content found in PDF');
|
throw new Error('No text content found in PDF');
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
|
||||||
let processedLength = 0;
|
let processedLength = 0;
|
||||||
let currentTime = 0;
|
let bookId: string = providedBookId || '';
|
||||||
|
|
||||||
|
// If we have a bookId, check for existing chapters to determine which indices already exist
|
||||||
|
const existingIndices = new Set<number>();
|
||||||
|
if (bookId) {
|
||||||
|
try {
|
||||||
|
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
||||||
|
if (existingResponse.ok) {
|
||||||
|
const existingData = await existingResponse.json();
|
||||||
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
|
for (const ch of existingData.chapters) {
|
||||||
|
existingIndices.add(ch.index);
|
||||||
|
}
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking existing chapters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Second pass: process text into audio
|
// Second pass: process text into audio
|
||||||
for (let i = 0; i < textPerPage.length; i++) {
|
for (let i = 0; i < textPerPage.length; i++) {
|
||||||
|
// Check for abort at the start of iteration
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
console.log('Generation cancelled by user');
|
||||||
return partialBuffer;
|
if (bookId) {
|
||||||
|
return bookId; // Return bookId with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = textPerPage[i];
|
const text = textPerPage[i];
|
||||||
|
|
||||||
|
// Skip pages that already exist on disk (supports non-contiguous indices)
|
||||||
|
if (existingIndices.has(i)) {
|
||||||
|
processedLength += text.length;
|
||||||
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
// Check for abort before starting TTS request
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
'x-openai-key': apiKey,
|
'x-openai-key': apiKey,
|
||||||
'x-openai-base-url': baseUrl,
|
'x-openai-base-url': baseUrl,
|
||||||
'x-tts-provider': ttsProvider,
|
'x-tts-provider': ttsProvider,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text,
|
text,
|
||||||
voice: voice,
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
speed: voiceSpeed,
|
speed: voiceSpeed,
|
||||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
}),
|
}),
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
|
|
@ -273,43 +317,239 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
audioChunks.push({
|
const chapterTitle = `Page ${i + 1}`;
|
||||||
buffer: audioBuffer,
|
|
||||||
title: `Page ${i + 1}`,
|
// Check for abort before sending to server
|
||||||
startTime: currentTime
|
if (signal?.aborted) {
|
||||||
|
console.log('Generation cancelled before saving page');
|
||||||
|
if (bookId) {
|
||||||
|
return bookId;
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex: i
|
||||||
|
}),
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a small pause between pages (1s of silence)
|
if (convertResponse.status === 499) {
|
||||||
const silenceBuffer = new ArrayBuffer(48000);
|
throw new Error('cancelled');
|
||||||
audioChunks.push({
|
}
|
||||||
buffer: silenceBuffer,
|
|
||||||
startTime: currentTime + (audioBuffer.byteLength / 48000)
|
if (!convertResponse.ok) {
|
||||||
});
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
if (!bookId) {
|
||||||
|
bookId = returnedBookId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about completed chapter
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
currentTime += (audioBuffer.byteLength + 48000) / 48000;
|
|
||||||
processedLength += text.length;
|
processedLength += text.length;
|
||||||
onProgress((processedLength / totalLength) * 100);
|
onProgress((processedLength / totalLength) * 100);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
console.log('TTS request aborted');
|
console.log('TTS request aborted, returning partial progress');
|
||||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
if (bookId) {
|
||||||
return partialBuffer;
|
return bookId; // Return with partial progress
|
||||||
|
}
|
||||||
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
console.error('Error processing page:', error);
|
console.error('Error processing page:', error);
|
||||||
|
|
||||||
|
// Notify about error
|
||||||
|
if (onChapterComplete) {
|
||||||
|
onChapterComplete({
|
||||||
|
index: i,
|
||||||
|
title: `Page ${i + 1}`,
|
||||||
|
status: 'error',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioChunks.length === 0) {
|
if (!bookId) {
|
||||||
throw new Error('No audio was generated from the PDF content');
|
throw new Error('No audio was generated from the PDF content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
return bookId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating audiobook:', error);
|
console.error('Error creating audiobook:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider]);
|
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates a specific chapter (page) of the PDF audiobook
|
||||||
|
*/
|
||||||
|
const regenerateChapter = useCallback(async (
|
||||||
|
chapterIndex: number,
|
||||||
|
bookId: string,
|
||||||
|
format: 'mp3' | 'm4b',
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
|
try {
|
||||||
|
if (!pdfDocument) {
|
||||||
|
throw new Error('No PDF document loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Chapter indices are based on non-empty pages used during generation.
|
||||||
|
// Build a mapping of "chapterIndex" -> actual PDF page number (1-based).
|
||||||
|
const nonEmptyPages: number[] = [];
|
||||||
|
for (let page = 1; page <= pdfDocument.numPages; page++) {
|
||||||
|
const pageText = await extractTextFromPDF(pdfDocument, page, {
|
||||||
|
header: headerMargin,
|
||||||
|
footer: footerMargin,
|
||||||
|
left: leftMargin,
|
||||||
|
right: rightMargin
|
||||||
|
});
|
||||||
|
if (pageText.trim()) {
|
||||||
|
nonEmptyPages.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapterIndex < 0 || chapterIndex >= nonEmptyPages.length) {
|
||||||
|
throw new Error('Invalid chapter index');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = nonEmptyPages[chapterIndex];
|
||||||
|
|
||||||
|
// Extract text from the mapped page
|
||||||
|
const text = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||||
|
header: headerMargin,
|
||||||
|
footer: footerMargin,
|
||||||
|
left: leftMargin,
|
||||||
|
right: rightMargin
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) {
|
||||||
|
throw new Error('No text content found on page');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use logical chapter numbering (index + 1) to match original generation titles
|
||||||
|
const chapterTitle = `Page ${chapterIndex + 1}`;
|
||||||
|
|
||||||
|
// Generate audio with retry logic
|
||||||
|
const audioBuffer = await withRetry(
|
||||||
|
async () => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey,
|
||||||
|
'x-openai-base-url': baseUrl,
|
||||||
|
'x-tts-provider': ttsProvider,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: trimmedText,
|
||||||
|
voice: voice || (ttsProvider === 'openai' ? 'alloy' : (ttsProvider === 'deepinfra' ? 'af_bella' : 'af_sarah')),
|
||||||
|
speed: voiceSpeed,
|
||||||
|
format: 'mp3',
|
||||||
|
model: ttsModel,
|
||||||
|
instructions: ttsModel === 'gpt-4o-mini-tts' ? ttsInstructions : undefined
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ttsResponse.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await ttsResponse.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 5000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error('Page regeneration cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to server for conversion and storage
|
||||||
|
const convertResponse = await fetch('/api/audio/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapterTitle,
|
||||||
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
|
bookId,
|
||||||
|
format,
|
||||||
|
chapterIndex
|
||||||
|
}),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (convertResponse.status === 499) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!convertResponse.ok) {
|
||||||
|
throw new Error('Failed to convert audio chapter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: returnedIndex,
|
||||||
|
title: chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed',
|
||||||
|
bookId,
|
||||||
|
format
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
throw new Error('Page regeneration cancelled');
|
||||||
|
}
|
||||||
|
console.error('Error regenerating page:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, apiKey, baseUrl, voice, voiceSpeed, ttsProvider, ttsModel, ttsInstructions]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect hook to initialize TTS as non-EPUB mode
|
* Effect hook to initialize TTS as non-EPUB mode
|
||||||
|
|
@ -334,6 +574,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
handleTextClick,
|
handleTextClick,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -347,6 +588,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
|
regenerateChapter,
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
readerArea: {
|
readerArea: {
|
||||||
...baseStyle.readerArea,
|
...baseStyle.readerArea,
|
||||||
backgroundColor: colors.base,
|
backgroundColor: colors.base,
|
||||||
|
height: '100%',
|
||||||
},
|
},
|
||||||
titleArea: {
|
titleArea: {
|
||||||
...baseStyle.titleArea,
|
...baseStyle.titleArea,
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,12 @@ export function useTimeEstimation(): TimeEstimation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastProgressUpdateRef.current = currentTime;
|
|
||||||
|
|
||||||
if (shouldSkipUpdate) {
|
if (shouldSkipUpdate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastProgressUpdateRef.current = currentTime;
|
||||||
|
|
||||||
const history = progressHistoryRef.current;
|
const history = progressHistoryRef.current;
|
||||||
|
|
||||||
if (history.length < 2) {
|
if (history.length < 2) {
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ export const combineAudioChunks = async (
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chapterTitle: chunk.title,
|
chapterTitle: chunk.title,
|
||||||
buffer: Array.from(new Uint8Array(chunk.buffer)),
|
buffer: Array.from(new Uint8Array(chunk.buffer)),
|
||||||
bookId // Will be undefined for first chunk, then set for subsequent ones
|
bookId, // Will be undefined for first chunk, then set for subsequent ones
|
||||||
|
format
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -106,8 +107,8 @@ export const combineAudioChunks = async (
|
||||||
throw new Error('No book ID received from server');
|
throw new Error('No book ID received from server');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the final combined M4B file
|
// Get the final combined M4B/MP3 file
|
||||||
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}`);
|
const m4bResponse = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`);
|
||||||
if (!m4bResponse.ok) {
|
if (!m4bResponse.ok) {
|
||||||
throw new Error('Failed to get combined M4B');
|
throw new Error('Failed to get combined M4B');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export default {
|
||||||
base: "var(--base)",
|
base: "var(--base)",
|
||||||
offbase: "var(--offbase)",
|
offbase: "var(--offbase)",
|
||||||
accent: "var(--accent)",
|
accent: "var(--accent)",
|
||||||
|
"secondary-accent": "var(--secondary-accent)",
|
||||||
muted: "var(--muted)",
|
muted: "var(--muted)",
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue