Working epub viewer

This commit is contained in:
Richard Roberson 2025-02-08 15:55:31 -07:00
parent 7d667b2114
commit 2fa91a2448
12 changed files with 882 additions and 303 deletions

773
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@
"react-dropzone": "^14.3.5",
"react-hot-toast": "^2.5.1",
"react-pdf": "^9.2.1",
"react-reader": "^2.0.12",
"string-similarity": "^4.0.4",
"uuid": "^11.0.5"
},

View file

@ -2,9 +2,54 @@
import { useParams } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { EPUBViewer } from '@/components/EPUBViewer';
export default function EPUBPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadDocument = useCallback(async () => {
if (!isLoading) return;
try {
if (!id) {
setError('Document not found');
return;
}
await setCurrentDocument(id as string);
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
} finally {
setIsLoading(false);
}
}, [isLoading, id, setCurrentDocument]);
useEffect(() => {
loadDocument();
}, [loadDocument]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => clearCurrDoc()}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Documents
</Link>
</div>
);
}
return (
<>
@ -13,7 +58,7 @@ export default function EPUBPage() {
<div className="flex items-center gap-2">
<Link
href="/"
onClick={() => { }}
onClick={() => clearCurrDoc()}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
@ -23,11 +68,17 @@ export default function EPUBPage() {
</Link>
</div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{id}
{isLoading ? 'Loading...' : currDocName}
</h1>
</div>
</div>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<EPUBViewer className="p-4" />
)}
</>
);
}
}

View file

@ -1,20 +1,27 @@
'use client';
import { ReactNode } from 'react';
import { DocumentProvider } from '@/contexts/DocumentContext';
import { PDFProvider } from '@/contexts/PDFContext';
import { EPUBProvider } from '@/contexts/EPUBContext';
import { TTSProvider } from '@/contexts/TTSContext';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<ConfigProvider>
<TTSProvider>
<PDFProvider>
{children}
</PDFProvider>
</TTSProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
{children}
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</ThemeProvider>
);

View file

@ -1,9 +1,8 @@
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';
import { useDocuments } from '@/contexts/DocumentContext';
type DocumentToDelete = {
id: string;
@ -12,8 +11,15 @@ type DocumentToDelete = {
};
export function DocumentList() {
const { documents: pdfDocs, removeDocument: removePDF, isDocsLoading: isPDFLoading } = usePDF();
const { documents: epubDocs, removeDocument: removeEPUB, isLoading: isEPUBLoading } = useEpubDocuments();
const {
pdfDocs,
removePDFDocument: removePDF,
isPDFLoading,
epubDocs,
removeEPUBDocument: removeEPUB,
isEPUBLoading,
} = useDocuments();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);

View file

@ -2,17 +2,15 @@
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';
import { useDocuments } from '@/contexts/DocumentContext';
interface DocumentUploaderProps {
className?: string;
}
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
const { addDocument: addPDF } = usePDF();
const { addDocument: addEPUB } = useEpubDocuments();
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);

View file

@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
ssr: false,
loading: () => <DocumentSkeleton />
});
interface EPUBViewerProps {
className?: string;
}
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
const { currDocData, currDocName } = useEPUB(); // Changed from currDocURL
const [location, setLocation] = useState<string | number>(0);
if (!currDocData) {
return <DocumentSkeleton />;
}
return (
<div style={{ height: '100vh' }} className={className}>
<ReactReader
location={location}
locationChanged={setLocation}
url={currDocData} // ReactReader can accept ArrayBuffer directly
title={currDocName}
showToc={true}
/>
</div>
);
}

View file

@ -0,0 +1,61 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
import { useEPUBDocuments } from '@/hooks/epub/useEPUBDocuments';
import { PDFDocument, EPUBDocument } from '@/utils/indexedDB';
interface DocumentContextType {
// PDF Documents
pdfDocs: PDFDocument[];
addPDFDocument: (file: File) => Promise<string>;
removePDFDocument: (id: string) => Promise<void>;
isPDFLoading: boolean;
// EPUB Documents
epubDocs: EPUBDocument[];
addEPUBDocument: (file: File) => Promise<string>;
removeEPUBDocument: (id: string) => Promise<void>;
isEPUBLoading: boolean;
}
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
export function DocumentProvider({ children }: { children: ReactNode }) {
const {
documents: pdfDocs,
addDocument: addPDFDocument,
removeDocument: removePDFDocument,
isLoading: isPDFLoading
} = usePDFDocuments();
const {
documents: epubDocs,
addDocument: addEPUBDocument,
removeDocument: removeEPUBDocument,
isLoading: isEPUBLoading
} = useEPUBDocuments();
return (
<DocumentContext.Provider value={{
pdfDocs,
addPDFDocument,
removePDFDocument,
isPDFLoading,
epubDocs,
addEPUBDocument,
removeEPUBDocument,
isEPUBLoading
}}>
{children}
</DocumentContext.Provider>
);
}
export function useDocuments() {
const context = useContext(DocumentContext);
if (context === undefined) {
throw new Error('useDocuments must be used within a DocumentProvider');
}
return context;
}

View file

@ -0,0 +1,132 @@
'use client';
import {
createContext,
useContext,
useState,
ReactNode,
useCallback,
useMemo,
} from 'react';
import { indexedDBService } from '@/utils/indexedDB';
import { useTTS } from '@/contexts/TTSContext';
interface EPUBContextType {
// Current document state
currDocData: ArrayBuffer | undefined; // Changed back to currDocData
currDocName: string | undefined;
currDocPages: number | undefined;
currDocPage: number;
currDocText: string | undefined;
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
// EPUB functionality
onDocumentLoadSuccess: ({ numPages }: { numPages: number }) => void;
}
// Create the context
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
/**
* EPUBProvider Component
*
* Main provider component that manages EPUB state and functionality.
* Handles document loading, text processing, and integration with TTS.
*/
export function EPUBProvider({ children }: { children: ReactNode }) {
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages } = useTTS();
// Current document state
const [currDocData, setCurrDocData] = useState<ArrayBuffer>(); // Changed back to currDocData
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
/**
* Handles successful document load
*/
const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => {
console.log('EPUB loaded:', numPages);
setCurrDocPages(numPages);
}, [setCurrDocPages]);
/**
* Clears the current document state
*/
const clearCurrDoc = useCallback(() => {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
setCurrDocPages(undefined);
setTTSText('');
}, [setCurrDocPages, setTTSText]);
/**
* Sets the current document based on its ID
*/
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try {
const doc = await indexedDBService.getEPUBDocument(id);
if (doc) {
console.log('Retrieved document size:', doc.size);
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
if (doc.data.byteLength === 0) {
console.error('Retrieved ArrayBuffer is empty');
throw new Error('Empty document data');
}
setCurrDocName(doc.name);
setCurrDocData(doc.data); // Store ArrayBuffer directly
} else {
console.error('Document not found in IndexedDB');
}
} catch (error) {
console.error('Failed to get EPUB document:', error);
clearCurrDoc(); // Clean up on error
}
}, [clearCurrDoc]);
// Context value memoization
const contextValue = useMemo(
() => ({
onDocumentLoadSuccess,
setCurrentDocument,
currDocData, // Changed back to currDocData
currDocName,
currDocPages,
currDocPage,
currDocText,
clearCurrDoc,
}),
[
onDocumentLoadSuccess,
setCurrentDocument,
currDocData, // Changed back to currDocData
currDocName,
currDocPages,
currDocPage,
currDocText,
clearCurrDoc,
]
);
return (
<EPUBContext.Provider value={contextValue}>
{children}
</EPUBContext.Provider>
);
}
/**
* Custom hook to consume the EPUB context
* Ensures the context is used within a provider
*/
export function useEPUB() {
const context = useContext(EPUBContext);
if (context === undefined) {
throw new Error('useEPUB must be used within an EPUBProvider');
}
return context;
}

View file

@ -23,7 +23,7 @@ import {
useMemo,
} from 'react';
import { indexedDBService, type PDFDocument } from '@/utils/indexedDB';
import { indexedDBService } from '@/utils/indexedDB';
import { useTTS } from '@/contexts/TTSContext';
import {
extractTextFromPDF,
@ -32,18 +32,11 @@ import {
clearHighlights,
handleTextClick,
} from '@/utils/pdf';
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
/**
* Interface defining all available methods and properties in the PDF context
*/
interface PDFContextType {
// PDF document store management
addDocument: (file: File) => Promise<string>;
documents: PDFDocument[];
removeDocument: (id: string) => Promise<void>;
isDocsLoading: boolean;
// Current document state
currDocURL: string | undefined;
currDocName: string | undefined;
@ -76,15 +69,7 @@ const PDFContext = createContext<PDFContextType | undefined>(undefined);
* Handles document loading, text processing, and integration with TTS.
*/
export function PDFProvider({ children }: { children: ReactNode }) {
const {
setText: setTTSText,
currDocPage,
currDocPages,
setCurrDocPages,
} = useTTS();
// PDF Documents hook
const { addDocument, documents, removeDocument, isLoading: isDocsLoading } = usePDFDocuments();
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages } = useTTS();
// Current document state
const [currDocURL, setCurrDocURL] = useState<string>();
@ -157,10 +142,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
// Context value memoization
const contextValue = useMemo(
() => ({
addDocument,
documents,
removeDocument,
isDocsLoading,
onDocumentLoadSuccess,
setCurrentDocument,
currDocURL,
@ -174,10 +155,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
handleTextClick,
}),
[
addDocument,
documents,
removeDocument,
isDocsLoading,
onDocumentLoadSuccess,
setCurrentDocument,
currDocURL,

View file

@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import { indexedDBService, type EPUBDocument } from '@/utils/indexedDB';
import { useConfig } from '@/contexts/ConfigContext';
export function useEpubDocuments() {
export function useEPUBDocuments() {
const { isDBReady } = useConfig();
const [documents, setDocuments] = useState<EPUBDocument[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -13,7 +13,7 @@ export function useEpubDocuments() {
const loadDocuments = useCallback(async () => {
if (isDBReady) {
try {
const docs = await indexedDBService.getAllEpubDocuments();
const docs = await indexedDBService.getAllEPUBDocuments();
setDocuments(docs);
} catch (error) {
console.error('Failed to load EPUB documents:', error);
@ -29,16 +29,21 @@ export function useEpubDocuments() {
const addDocument = useCallback(async (file: File): Promise<string> => {
const id = uuidv4();
const arrayBuffer = await file.arrayBuffer();
console.log('Original file size:', file.size);
console.log('ArrayBuffer size:', arrayBuffer.byteLength);
const newDoc: EPUBDocument = {
id,
name: file.name,
size: file.size,
lastModified: file.lastModified,
data: new Blob([file], { type: file.type }),
data: arrayBuffer,
};
try {
await indexedDBService.addEpubDocument(newDoc);
await indexedDBService.addEPUBDocument(newDoc);
setDocuments((prev) => [...prev, newDoc]);
return id;
} catch (error) {
@ -49,7 +54,7 @@ export function useEpubDocuments() {
const removeDocument = useCallback(async (id: string): Promise<void> => {
try {
await indexedDBService.removeEpubDocument(id);
await indexedDBService.removeEPUBDocument(id);
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
} catch (error) {
console.error('Failed to remove EPUB document:', error);

View file

@ -17,7 +17,7 @@ export interface EPUBDocument {
name: string;
size: number;
lastModified: number;
data: Blob;
data: ArrayBuffer; // Changed from Blob to ArrayBuffer
}
export interface Config {
@ -196,31 +196,49 @@ class IndexedDBService {
}
// Add EPUB Document Methods
async addEpubDocument(document: EPUBDocument): Promise<void> {
async addEPUBDocument(document: EPUBDocument): Promise<void> {
if (!this.db) {
await this.init();
}
return new Promise((resolve, reject) => {
try {
if (document.data.byteLength === 0) {
throw new Error('Cannot store empty ArrayBuffer');
}
console.log('Storing document:', {
name: document.name,
size: document.size,
actualSize: document.data.byteLength
});
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
const store = transaction.objectStore(EPUB_STORE_NAME);
const request = store.put(document);
// Create a structured clone of the document to ensure proper storage
const request = store.put({
...document,
data: document.data.slice(0) // Create a copy of the ArrayBuffer
});
request.onerror = (event) => {
console.error('Error storing document:', (event.target as IDBRequest).error);
reject((event.target as IDBRequest).error);
};
transaction.oncomplete = () => {
console.log('Document stored successfully');
resolve();
};
} catch (error) {
console.error('Error in addEPUBDocument:', error);
reject(error);
}
});
}
async getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
async getEPUBDocument(id: string): Promise<EPUBDocument | undefined> {
if (!this.db) {
await this.init();
}
@ -232,19 +250,29 @@ class IndexedDBService {
const request = store.get(id);
request.onerror = (event) => {
console.error('Error fetching document:', (event.target as IDBRequest).error);
reject((event.target as IDBRequest).error);
};
request.onsuccess = () => {
resolve(request.result);
const doc = request.result;
if (doc) {
console.log('Retrieved document from DB:', {
name: doc.name,
size: doc.size,
actualSize: doc.data.byteLength
});
}
resolve(doc);
};
} catch (error) {
console.error('Error in getEPUBDocument:', error);
reject(error);
}
});
}
async getAllEpubDocuments(): Promise<EPUBDocument[]> {
async getAllEPUBDocuments(): Promise<EPUBDocument[]> {
if (!this.db) {
await this.init();
}
@ -268,7 +296,7 @@ class IndexedDBService {
});
}
async removeEpubDocument(id: string): Promise<void> {
async removeEPUBDocument(id: string): Promise<void> {
if (!this.db) {
await this.init();
}