openreader/src/components/DocumentUploader.tsx
Richard Roberson 42665884d7 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
2025-11-11 13:32:30 -07:00

138 lines
5.1 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface DocumentUploaderProps {
className?: string;
variant?: 'default' | 'compact';
}
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
const {
addPDFDocument: addPDF,
addEPUBDocument: addEPUB,
addHTMLDocument: addHTML
} = useDocuments();
const [isUploading, setIsUploading] = useState(false);
const [isConverting, setIsConverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const convertDocxToPdf = async (file: File): Promise<File> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/documents/docx-to-pdf', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to convert DOCX to PDF');
}
const pdfBlob = await response.blob();
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
type: 'application/pdf',
});
};
const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (!acceptedFiles || acceptedFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
for (const file of acceptedFiles) {
if (file.type === 'application/pdf') {
await addPDF(file);
} else if (file.type === 'application/epub+zip') {
await addEPUB(file);
} else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) {
await addHTML(file);
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setIsUploading(false);
setIsConverting(true);
const pdfFile = await convertDocxToPdf(file);
await addPDF(pdfFile);
setIsConverting(false);
setIsUploading(true);
}
}
} catch (err) {
setError('Failed to upload file. Please try again.');
console.error('Upload error:', err);
} finally {
setIsUploading(false);
setIsConverting(false);
}
}, [addHTML, addPDF, addEPUB]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'application/epub+zip': ['.epub'],
'text/plain': ['.txt'],
'text/markdown': ['.md'],
...(isDev ? {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
} : {})
},
multiple: true,
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 (
<div
{...getRootProps()}
className={`${containerBase} ${paddingClass}`}
>
<input {...getInputProps()} />
{variant === 'compact' ? (
<div className="flex items-center gap-2 text-left">
<UploadIcon className="w-5 h-5 text-muted" />
{isUploading ? (
<p className="text-xs font-medium text-foreground">Uploading</p>
) : isConverting ? (
<p className="text-xs font-medium text-foreground">Converting DOCX</p>
) : (
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-foreground">
{isDragActive ? 'Drop files here' : 'Drop files or click'}
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center text-center">
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
{isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
) : isConverting ? (
<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>
);
}