Prepare epub work

This commit is contained in:
Richard Roberson 2025-02-08 13:07:36 -07:00
parent e3167a18ea
commit 7d667b2114
9 changed files with 294 additions and 50 deletions

View file

@ -0,0 +1,33 @@
'use client';
import { useParams } from "next/navigation";
import Link from 'next/link';
export default function EPUBPage() {
const { id } = useParams();
return (
<>
<div className="p-2 pb-2 border-b border-offbase">
<div className="flex flex-wrap items-center justify-between">
<div className="flex items-center gap-2">
<Link
href="/"
onClick={() => { }}
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]"
>
<svg className="w-4 h-4 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>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{id}
</h1>
</div>
</div>
</>
);
}

View file

@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { PDFUploader } from '@/components/PDFUploader';
import { DocumentUploader } from '@/components/DocumentUploader';
import { DocumentList } from '@/components/DocumentList';
import { SettingsModal } from '@/components/SettingsModal';
import { SettingsIcon } from '@/components/icons/Icons';
@ -23,7 +23,7 @@ export default function Home() {
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
<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="flex flex-col items-center gap-5">
<PDFUploader className='max-w-xl' />
<DocumentUploader className='max-w-xl' />
<DocumentList />
</div>
<SettingsModal isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />

View file

@ -5,7 +5,7 @@ import { usePDF } from '@/contexts/PDFContext';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { PDFSkeleton } from '@/components/PDFSkeleton';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { Button } from '@headlessui/react';
import { PDFViewSettings } from '@/components/PDFViewSettings';
@ -16,7 +16,7 @@ const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((module) => module.PDFViewer),
{
ssr: false,
loading: () => <PDFSkeleton />
loading: () => <DocumentSkeleton />
}
);
@ -118,7 +118,7 @@ export default function PDFViewerPage() {
</div>
{isLoading ? (
<div className="p-4">
<PDFSkeleton />
<DocumentSkeleton />
</div>
) : (
<PDFViewer zoomLevel={zoomLevel} />

View file

@ -1,27 +1,44 @@
import { usePDF } from '@/contexts/PDFContext';
import { useEpubDocuments } from '@/hooks/epub/useEpubDocuments';
import Link from 'next/link';
import { Button, Dialog } from '@headlessui/react';
import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react';
import { Fragment, useState } from 'react';
type DocumentToDelete = {
id: string;
name: string;
type: 'pdf' | 'epub';
};
export function DocumentList() {
const { documents, removeDocument, isDocsLoading } = usePDF();
const { documents: pdfDocs, removeDocument: removePDF, isDocsLoading: isPDFLoading } = usePDF();
const { documents: epubDocs, removeDocument: removeEPUB, isLoading: isEPUBLoading } = useEpubDocuments();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [documentToDelete, setDocumentToDelete] = useState<{ id: string; name: string } | null>(null);
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
const handleDelete = async () => {
if (documentToDelete) {
try {
await removeDocument(documentToDelete.id);
setIsDeleteDialogOpen(false);
setDocumentToDelete(null);
} catch (err) {
console.error('Failed to remove document:', err);
if (!documentToDelete) return;
try {
if (documentToDelete.type === 'pdf') {
await removePDF(documentToDelete.id);
} else {
await removeEPUB(documentToDelete.id);
}
setIsDeleteDialogOpen(false);
setDocumentToDelete(null);
} catch (err) {
console.error('Failed to remove document:', err);
}
};
if (isDocsLoading) {
const allDocuments = [
...pdfDocs.map(doc => ({ ...doc, type: 'pdf' as const })),
...epubDocs.map(doc => ({ ...doc, type: 'epub' as const })),
].sort((a, b) => b.lastModified - a.lastModified);
if (isPDFLoading || isEPUBLoading) {
return (
<div className="w-full text-center text-muted">
Loading documents...
@ -29,7 +46,7 @@ export function DocumentList() {
);
}
if (documents.length === 0) {
if (allDocuments.length === 0) {
return (
<div className="w-full text-center text-muted">
No documents uploaded yet
@ -41,9 +58,9 @@ export function DocumentList() {
<div className="w-full mx-auto">
<h2 className="text-xl font-semibold mb-4 text-foreground">Your Documents</h2>
<div className="bg-background rounded-lg shadow p-2 space-y-2">
{documents.map((doc) => (
{allDocuments.map((doc) => (
<Transition
key={doc.id}
key={`${doc.type}-${doc.id}`}
show={true}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
@ -52,20 +69,29 @@ export function DocumentList() {
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<div
className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors"
>
<div className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors">
<Link
href={`/pdf/${encodeURIComponent(doc.id)}`}
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
className="flex items-center space-x-4 flex-1 min-w-0"
>
<div className="flex-shrink-0">
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
{doc.type === 'pdf' ? (
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
) : (
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)}
</div>
<div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
<div className="flex items-center gap-2">
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
<span className="text-xs rounded-full bg-muted/20 text-muted uppercase">
{doc.type}
</span>
</div>
<p className="text-xs sm:text-sm text-muted truncate">
{(doc.size / 1024 / 1024).toFixed(2)} MB
</p>
@ -73,7 +99,7 @@ export function DocumentList() {
</Link>
<Button
onClick={() => {
setDocumentToDelete({ id: doc.id, name: doc.name });
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type });
setIsDeleteDialogOpen(true);
}}
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"

View file

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import toast from 'react-hot-toast';
export function PDFSkeleton() {
export function DocumentSkeleton() {
useEffect(() => {
const timer = setTimeout(() => {
toast('There might be an issue with the file import. Please try again.', {

View file

@ -4,36 +4,44 @@ import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { usePDF } from '@/contexts/PDFContext';
import { UploadIcon } from '@/components/icons/Icons';
import { useEpubDocuments } from '@/hooks/epub/useEpubDocuments';
interface PDFUploaderProps {
interface DocumentUploaderProps {
className?: string;
}
export function PDFUploader({ className = '' }: PDFUploaderProps) {
const { addDocument } = usePDF();
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
const { addDocument: addPDF } = usePDF();
const { addDocument: addEPUB } = useEpubDocuments();
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file && file.type === 'application/pdf') {
setIsUploading(true);
setError(null);
try {
await addDocument(file);
} catch (err) {
setError('Failed to upload PDF. Please try again.');
console.error('Upload error:', err);
} finally {
setIsUploading(false);
if (!file) return;
setIsUploading(true);
setError(null);
try {
if (file.type === 'application/pdf') {
await addPDF(file);
} else if (file.type === 'application/epub+zip') {
await addEPUB(file);
}
} catch (err) {
setError('Failed to upload file. Please try again.');
console.error('Upload error:', err);
} finally {
setIsUploading(false);
}
}, [addDocument]);
}, [addPDF, addEPUB]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf']
'application/pdf': ['.pdf'],
'application/epub+zip': ['.epub']
},
multiple: false,
disabled: isUploading
@ -56,15 +64,15 @@ export function PDFUploader({ className = '' }: PDFUploaderProps) {
{isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">
Uploading PDF...
Uploading file...
</p>
) : (
<>
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your PDF here' : 'Drop your PDF here, or click to select'}
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
</p>
<p className="text-xs sm:text-sm text-muted">
Only PDF files are currently accepted
PDF and EPUB files are accepted
</p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
</>

View file

@ -4,7 +4,7 @@ import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
import { Document, Page } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { PDFSkeleton } from './PDFSkeleton';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { usePDF } from '@/contexts/PDFContext';
import TTSPlayer from '@/components/player/TTSPlayer';
@ -165,8 +165,8 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
return (
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
<Document
loading={<PDFSkeleton />}
noData={<PDFSkeleton />}
loading={<DocumentSkeleton />}
noData={<DocumentSkeleton />}
file={currDocURL}
onLoadSuccess={(pdf) => {
onDocumentLoadSuccess(pdf);

View file

@ -0,0 +1,66 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { indexedDBService, type EPUBDocument } from '@/utils/indexedDB';
import { useConfig } from '@/contexts/ConfigContext';
export function useEpubDocuments() {
const { isDBReady } = useConfig();
const [documents, setDocuments] = useState<EPUBDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadDocuments = useCallback(async () => {
if (isDBReady) {
try {
const docs = await indexedDBService.getAllEpubDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to load EPUB documents:', error);
} finally {
setIsLoading(false);
}
}
}, [isDBReady]);
useEffect(() => {
loadDocuments();
}, [loadDocuments]);
const addDocument = useCallback(async (file: File): Promise<string> => {
const id = uuidv4();
const newDoc: EPUBDocument = {
id,
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: new Blob([file], { type: file.type }),
};
try {
await indexedDBService.addEpubDocument(newDoc);
setDocuments((prev) => [...prev, newDoc]);
return id;
} catch (error) {
console.error('Failed to add EPUB document:', error);
throw error;
}
}, []);
const removeDocument = useCallback(async (id: string): Promise<void> => {
try {
await indexedDBService.removeEpubDocument(id);
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
} catch (error) {
console.error('Failed to remove EPUB document:', error);
throw error;
}
}, []);
return {
documents,
isLoading,
addDocument,
removeDocument,
};
}

View file

@ -1,6 +1,7 @@
const DB_NAME = 'openreader-db';
const DB_VERSION = 1;
const DB_VERSION = 2; // Increased version number
const PDF_STORE_NAME = 'pdf-documents';
const EPUB_STORE_NAME = 'epub-documents';
const CONFIG_STORE_NAME = 'config';
export interface PDFDocument {
@ -11,6 +12,14 @@ export interface PDFDocument {
data: Blob;
}
export interface EPUBDocument {
id: string;
name: string;
size: number;
lastModified: number;
data: Blob;
}
export interface Config {
key: string;
value: string;
@ -54,6 +63,11 @@ class IndexedDBService {
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
console.log('Creating EPUB documents store...');
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) {
console.log('Creating config store...');
db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' });
@ -181,6 +195,103 @@ class IndexedDBService {
});
}
// Add EPUB Document Methods
async addEpubDocument(document: EPUBDocument): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
const store = transaction.objectStore(EPUB_STORE_NAME);
const request = store.put(document);
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
transaction.oncomplete = () => {
resolve();
};
} catch (error) {
reject(error);
}
});
}
async getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
const store = transaction.objectStore(EPUB_STORE_NAME);
const request = store.get(id);
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
request.onsuccess = () => {
resolve(request.result);
};
} catch (error) {
reject(error);
}
});
}
async getAllEpubDocuments(): Promise<EPUBDocument[]> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
const store = transaction.objectStore(EPUB_STORE_NAME);
const request = store.getAll();
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
request.onsuccess = () => {
resolve(request.result || []);
};
} catch (error) {
reject(error);
}
});
}
async removeEpubDocument(id: string): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
const store = transaction.objectStore(EPUB_STORE_NAME);
const request = store.delete(id);
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
transaction.oncomplete = () => {
resolve();
};
} catch (error) {
reject(error);
}
});
}
// Config Methods
async setConfigItem(key: string, value: string): Promise<void> {
if (!this.db) {